Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b10ab692 | |||
| cc4f1cd389 | |||
| 96328bb403 | |||
| 5fb7597dfd | |||
| ba6417e72f | |||
| 329e016f8d | |||
| b71e7ea1e3 | |||
| 82069053be | |||
| cf9102939f | |||
| ef50c8911e | |||
| c4091d4cee | |||
| c9db440c85 | |||
| bed3729206 | |||
| fb4a9e5e27 | |||
| 6cae652643 | |||
| 43bbe971eb | |||
| 4f773e1c1b | |||
| 327e7882b5 | |||
| c4f99f9b2b | |||
| baf9b41f44 | |||
| 39c0cb61da | |||
| f9d92811dc | |||
| ff3f5fcf3a |
14 changed files with 1444 additions and 180 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -1,3 +1,38 @@
|
||||||
|
# 0.1.8
|
||||||
|
|
||||||
|
* Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed
|
||||||
|
* Fixes for multi-line code blocks
|
||||||
|
* Fix URL href linking
|
||||||
|
* Render the history version dates in user's local timezone
|
||||||
|
|
||||||
|
# 0.1.7
|
||||||
|
|
||||||
|
* More fixes for shortcuts and move the Change Key button in settings
|
||||||
|
|
||||||
|
# 0.1.6
|
||||||
|
|
||||||
|
* Fix shortcuts for next/previous day to not collide with Normal text (Ctrl+N)
|
||||||
|
|
||||||
|
# 0.1.5
|
||||||
|
|
||||||
|
* Refactor schema to support versioning of pages. Add HistoryDialog and diff with ability to revert.
|
||||||
|
|
||||||
|
# 0.1.4
|
||||||
|
|
||||||
|
* Add auto-lock of app (configurable in Settings, defaults to 15 minutes)
|
||||||
|
* Add 'Report a bug' to Help nav
|
||||||
|
|
||||||
|
# 0.1.3
|
||||||
|
|
||||||
|
* Fix bold toggle
|
||||||
|
* Improvements to preview size in search results
|
||||||
|
* Make URLs highlighted and clickable (Ctrl+click)
|
||||||
|
* 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)
|
||||||
|
* Abbreviated toolbar symbols to keep things tidier. Add tooltips
|
||||||
|
* Add ability to export the database to different formats
|
||||||
|
* Add Documentation/Help menu
|
||||||
|
|
||||||
# 0.1.2
|
# 0.1.2
|
||||||
|
|
||||||
* Switch from Markdown to HTML via QTextEdit, with a toolbar
|
* Switch from Markdown to HTML via QTextEdit, with a toolbar
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
|
||||||
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
|
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
|
||||||
|
|
||||||
To increase security, the SQLCipher key is requested when the app is opened, and is not written
|
To increase security, the SQLCipher key is requested when the app is opened, and is not written
|
||||||
to disk.
|
to disk unless the user configures it to be in the settings.
|
||||||
|
|
||||||
There is deliberately no network connectivity or syncing intended.
|
There is deliberately no network connectivity or syncing intended.
|
||||||
|
|
||||||
|
|
@ -19,22 +19,23 @@ 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
|
||||||
|
* All changes are version controlled, with ability to view/diff versions and revert
|
||||||
* 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
|
||||||
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
||||||
* 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
|
||||||
|
|
||||||
|
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
* Clone this repo or download the tarball from the releases page
|
* Clone this repo or download the tarball from the releases page
|
||||||
|
|
|
||||||
384
bouquin/db.py
384
bouquin/db.py
|
|
@ -1,15 +1,23 @@
|
||||||
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
|
||||||
class DBConfig:
|
class DBConfig:
|
||||||
path: Path
|
path: Path
|
||||||
key: str
|
key: str
|
||||||
|
idle_minutes: int = 15 # 0 = never lock
|
||||||
|
|
||||||
|
|
||||||
class DBManager:
|
class DBManager:
|
||||||
|
|
@ -18,14 +26,17 @@ class DBManager:
|
||||||
self.conn: sqlite.Connection | None = None
|
self.conn: sqlite.Connection | None = None
|
||||||
|
|
||||||
def connect(self) -> bool:
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Open, decrypt and install schema on the database.
|
||||||
|
"""
|
||||||
# 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 cipher_compatibility = 4;")
|
cur.execute("PRAGMA foreign_keys = ON;")
|
||||||
cur.execute("PRAGMA journal_mode = WAL;")
|
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
||||||
self.conn.commit()
|
|
||||||
try:
|
try:
|
||||||
self._integrity_ok()
|
self._integrity_ok()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -36,15 +47,18 @@ class DBManager:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _integrity_ok(self) -> bool:
|
def _integrity_ok(self) -> bool:
|
||||||
|
"""
|
||||||
|
Runs the cipher_integrity_check PRAGMA on the database.
|
||||||
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("PRAGMA cipher_integrity_check;")
|
cur.execute("PRAGMA cipher_integrity_check;")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
# OK
|
# OK: nothing returned
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Not OK
|
# Not OK: rows of problems returned
|
||||||
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
||||||
raise sqlite.IntegrityError(
|
raise sqlite.IntegrityError(
|
||||||
"SQLCipher integrity check failed"
|
"SQLCipher integrity check failed"
|
||||||
|
|
@ -52,16 +66,62 @@ class DBManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_schema(self) -> None:
|
def _ensure_schema(self) -> None:
|
||||||
|
"""
|
||||||
|
Install the expected schema on the database.
|
||||||
|
We also handle upgrades here.
|
||||||
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
# Always keep FKs on
|
||||||
|
cur.execute("PRAGMA foreign_keys = ON;")
|
||||||
|
|
||||||
|
# Create new versioned schema if missing (< 0.1.5)
|
||||||
|
cur.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
date TEXT PRIMARY KEY, -- yyyy-MM-dd
|
||||||
|
current_version_id INTEGER,
|
||||||
|
FOREIGN KEY(current_version_id) REFERENCES versions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS versions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
date TEXT NOT NULL, -- FK to pages.date
|
||||||
|
version_no INTEGER NOT NULL, -- 1,2,3… per date
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
note TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(date) REFERENCES pages(date) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
|
||||||
|
pre_0_1_5 = cur.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
|
||||||
|
).fetchone()
|
||||||
|
pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
|
||||||
|
|
||||||
|
if pre_0_1_5 and pages_empty:
|
||||||
|
# Seed pages and versions (all as version 1)
|
||||||
|
cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO versions(date, version_no, content) "
|
||||||
|
"SELECT date, 1, content FROM entries;"
|
||||||
|
)
|
||||||
|
# Point head to v1 for each page
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
UPDATE pages
|
||||||
date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
|
SET current_version_id = (
|
||||||
content TEXT NOT NULL
|
SELECT v.id FROM versions v
|
||||||
|
WHERE v.date = pages.date AND v.version_no = 1
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
cur.execute("PRAGMA user_version = 1;")
|
cur.execute("DROP TABLE IF EXISTS entries;")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def rekey(self, new_key: str) -> None:
|
def rekey(self, new_key: str) -> None:
|
||||||
|
|
@ -73,7 +133,7 @@ class DBManager:
|
||||||
raise RuntimeError("Database is not connected")
|
raise RuntimeError("Database is not connected")
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
# Change the encryption key of the currently open database
|
# Change the encryption key of the currently open database
|
||||||
cur.execute(f"PRAGMA rekey = '{new_key}';")
|
cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
# Close and reopen with the new key to verify and restore PRAGMAs
|
# Close and reopen with the new key to verify and restore PRAGMAs
|
||||||
|
|
@ -84,32 +144,306 @@ class DBManager:
|
||||||
raise sqlite.Error("Re-open failed after rekey")
|
raise sqlite.Error("Re-open failed after rekey")
|
||||||
|
|
||||||
def get_entry(self, date_iso: str) -> str:
|
def get_entry(self, date_iso: str) -> str:
|
||||||
|
"""
|
||||||
|
Get a single entry by its date.
|
||||||
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
|
row = cur.execute(
|
||||||
row = cur.fetchone()
|
"""
|
||||||
|
SELECT v.content
|
||||||
|
FROM pages p
|
||||||
|
JOIN versions v ON v.id = p.current_version_id
|
||||||
|
WHERE p.date = ?;
|
||||||
|
""",
|
||||||
|
(date_iso,),
|
||||||
|
).fetchone()
|
||||||
return row[0] if row else ""
|
return row[0] if row else ""
|
||||||
|
|
||||||
def upsert_entry(self, date_iso: str, content: str) -> None:
|
def upsert_entry(self, date_iso: str, content: str) -> None:
|
||||||
cur = self.conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO entries(date, content) VALUES(?, ?)
|
Insert or update an entry.
|
||||||
ON CONFLICT(date) DO UPDATE SET content = excluded.content;
|
"""
|
||||||
""",
|
# Make a new version and set it as current
|
||||||
(date_iso, content),
|
self.save_new_version(date_iso, content, note=None, set_current=True)
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def search_entries(self, text: str) -> list[str]:
|
def search_entries(self, text: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Search for entries by term. This only works against the latest
|
||||||
|
version of the page.
|
||||||
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
pattern = f"%{text}%"
|
pattern = f"%{text}%"
|
||||||
cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
|
rows = cur.execute(
|
||||||
return [r for r in cur.fetchall()]
|
"""
|
||||||
|
SELECT p.date, v.content
|
||||||
|
FROM pages AS p
|
||||||
|
JOIN versions AS v
|
||||||
|
ON v.id = p.current_version_id
|
||||||
|
WHERE TRIM(v.content) <> ''
|
||||||
|
AND v.content LIKE LOWER(?) ESCAPE '\\'
|
||||||
|
ORDER BY p.date DESC;
|
||||||
|
""",
|
||||||
|
(pattern,),
|
||||||
|
).fetchall()
|
||||||
|
return [(r[0], r[1]) for r in rows]
|
||||||
|
|
||||||
def dates_with_content(self) -> list[str]:
|
def dates_with_content(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Find all entries and return the dates of them.
|
||||||
|
This is used to mark the calendar days in bold if they contain entries.
|
||||||
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
rows = cur.execute(
|
||||||
return [r[0] for r in cur.fetchall()]
|
"""
|
||||||
|
SELECT p.date
|
||||||
|
FROM pages p
|
||||||
|
JOIN versions v ON v.id = p.current_version_id
|
||||||
|
WHERE TRIM(v.content) <> ''
|
||||||
|
ORDER BY p.date;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
# ------------------------- Versioning logic here ------------------------#
|
||||||
|
def save_new_version(
|
||||||
|
self,
|
||||||
|
date_iso: str,
|
||||||
|
content: str,
|
||||||
|
note: str | None = None,
|
||||||
|
set_current: bool = True,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Append a new version for this date. Returns (version_id, version_no).
|
||||||
|
If set_current=True, flips the page head to this new version.
|
||||||
|
"""
|
||||||
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
with self.conn: # transaction
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
# Ensure page row exists
|
||||||
|
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
|
||||||
|
# Next version number
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT COALESCE(MAX(version_no), 0) AS maxv FROM versions WHERE date=?;",
|
||||||
|
(date_iso,),
|
||||||
|
).fetchone()
|
||||||
|
next_ver = int(row["maxv"]) + 1
|
||||||
|
# Insert the version
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO versions(date, version_no, content, note) "
|
||||||
|
"VALUES (?,?,?,?);",
|
||||||
|
(date_iso, next_ver, content, note),
|
||||||
|
)
|
||||||
|
ver_id = cur.lastrowid
|
||||||
|
if set_current:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE pages SET current_version_id=? WHERE date=?;",
|
||||||
|
(ver_id, date_iso),
|
||||||
|
)
|
||||||
|
return ver_id, next_ver
|
||||||
|
|
||||||
|
def list_versions(self, date_iso: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Returns history for a given date (newest first), including which one is current.
|
||||||
|
Each item: {id, version_no, created_at, note, is_current}
|
||||||
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT v.id, v.version_no, v.created_at, v.note,
|
||||||
|
CASE WHEN v.id = p.current_version_id THEN 1 ELSE 0 END AS is_current
|
||||||
|
FROM versions v
|
||||||
|
LEFT JOIN pages p ON p.date = v.date
|
||||||
|
WHERE v.date = ?
|
||||||
|
ORDER BY v.version_no DESC;
|
||||||
|
""",
|
||||||
|
(date_iso,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def get_version(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
date_iso: str | None = None,
|
||||||
|
version_no: int | None = None,
|
||||||
|
version_id: int | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""
|
||||||
|
Fetch a specific version by (date, version_no) OR by version_id.
|
||||||
|
Returns a dict with keys: id, date, version_no, created_at, note, content.
|
||||||
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
if version_id is not None:
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT id, date, version_no, created_at, note, content "
|
||||||
|
"FROM versions WHERE id=?;",
|
||||||
|
(version_id,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
if date_iso is None or version_no is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Provide either version_id OR (date_iso and version_no)"
|
||||||
|
)
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT id, date, version_no, created_at, note, content "
|
||||||
|
"FROM versions WHERE date=? AND version_no=?;",
|
||||||
|
(date_iso, version_no),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def revert_to_version(
|
||||||
|
self,
|
||||||
|
date_iso: str,
|
||||||
|
*,
|
||||||
|
version_no: int | None = None,
|
||||||
|
version_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Point the page head (pages.current_version_id) to an existing version.
|
||||||
|
Fast revert: no content is rewritten.
|
||||||
|
"""
|
||||||
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
if version_no is None:
|
||||||
|
raise ValueError("Provide version_no or version_id")
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT id FROM versions WHERE date=? AND version_no=?;",
|
||||||
|
(date_iso, version_no),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Version not found for this date")
|
||||||
|
version_id = int(row["id"])
|
||||||
|
else:
|
||||||
|
# Ensure that version_id belongs to the given date
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None or row["date"] != date_iso:
|
||||||
|
raise ValueError("version_id does not belong to the given date")
|
||||||
|
|
||||||
|
with self.conn:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE pages SET current_version_id=? WHERE date=?;",
|
||||||
|
(version_id, date_iso),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------- Export logic here ------------------------#
|
||||||
|
def get_all_entries(self) -> List[Entry]:
|
||||||
|
"""
|
||||||
|
Get all entries. Used for exports.
|
||||||
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.date, v.content
|
||||||
|
FROM pages p
|
||||||
|
JOIN versions v ON v.id = p.current_version_id
|
||||||
|
ORDER BY p.date;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [(r[0], r[1]) for r in rows]
|
||||||
|
|
||||||
|
def export_json(
|
||||||
|
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Export to json.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,176 @@ from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
|
QTextCursor,
|
||||||
|
QTextFrameFormat,
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
QTextBlockFormat,
|
QTextBlockFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Slot
|
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
|
||||||
class Editor(QTextEdit):
|
class Editor(QTextEdit):
|
||||||
def __init__(self):
|
linkActivated = Signal(str)
|
||||||
super().__init__()
|
|
||||||
|
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
|
||||||
|
_CODE_BG = QColor(245, 245, 245)
|
||||||
|
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
self.setTabStopDistance(tab_w)
|
self.setTabStopDistance(tab_w)
|
||||||
|
|
||||||
|
self.setTextInteractionFlags(
|
||||||
|
Qt.TextInteractionFlag.TextEditorInteraction
|
||||||
|
| Qt.TextInteractionFlag.LinksAccessibleByMouse
|
||||||
|
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setAcceptRichText(True)
|
||||||
|
|
||||||
|
# Turn raw URLs into anchors
|
||||||
|
self._linkifying = False
|
||||||
|
self.textChanged.connect(self._linkify_document)
|
||||||
|
self.viewport().setMouseTracking(True)
|
||||||
|
|
||||||
|
def _find_code_frame(self, cursor=None):
|
||||||
|
"""Return the nearest ancestor frame that's one of our code frames, else None."""
|
||||||
|
if cursor is None:
|
||||||
|
cursor = self.textCursor()
|
||||||
|
f = cursor.currentFrame()
|
||||||
|
while f:
|
||||||
|
if f.frameFormat().property(self._CODE_FRAME_PROP):
|
||||||
|
return f
|
||||||
|
f = f.parentFrame()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_code_block(self, block) -> bool:
|
||||||
|
if not block.isValid():
|
||||||
|
return False
|
||||||
|
bf = block.blockFormat()
|
||||||
|
return bool(
|
||||||
|
bf.nonBreakableLines()
|
||||||
|
and bf.background().color().rgb() == self._CODE_BG.rgb()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _trim_url_end(self, url: str) -> str:
|
||||||
|
# strip common trailing punctuation not part of the URL
|
||||||
|
trimmed = url.rstrip(".,;:!?\"'")
|
||||||
|
# drop an unmatched closing ) or ] at the very end
|
||||||
|
if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
|
||||||
|
trimmed = trimmed[:-1]
|
||||||
|
if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
|
||||||
|
trimmed = trimmed[:-1]
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
def _linkify_document(self):
|
||||||
|
if self._linkifying:
|
||||||
|
return
|
||||||
|
self._linkifying = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
block = self.textCursor().block()
|
||||||
|
start_pos = block.position()
|
||||||
|
text = block.text()
|
||||||
|
|
||||||
|
cur = QTextCursor(self.document())
|
||||||
|
cur.beginEditBlock()
|
||||||
|
|
||||||
|
it = self._URL_RX.globalMatch(text)
|
||||||
|
while it.hasNext():
|
||||||
|
m = it.next()
|
||||||
|
s = start_pos + m.capturedStart()
|
||||||
|
raw = m.captured(0)
|
||||||
|
url = self._trim_url_end(raw)
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
e = s + len(url)
|
||||||
|
cur.setPosition(s)
|
||||||
|
cur.setPosition(e, QTextCursor.KeepAnchor)
|
||||||
|
|
||||||
|
if url.startswith("www."):
|
||||||
|
href = "https://" + url
|
||||||
|
else:
|
||||||
|
href = url
|
||||||
|
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setAnchor(True)
|
||||||
|
fmt.setAnchorHref(href) # always refresh to the latest full URL
|
||||||
|
fmt.setFontUnderline(True)
|
||||||
|
fmt.setForeground(Qt.blue)
|
||||||
|
|
||||||
|
cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling
|
||||||
|
|
||||||
|
cur.endEditBlock()
|
||||||
|
finally:
|
||||||
|
self._linkifying = False
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
|
||||||
|
href = self.anchorAt(e.pos())
|
||||||
|
if href:
|
||||||
|
QDesktopServices.openUrl(QUrl.fromUserInput(href))
|
||||||
|
self.linkActivated.emit(href)
|
||||||
|
return
|
||||||
|
super().mouseReleaseEvent(e)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, e):
|
||||||
|
if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
|
||||||
|
self.viewport().setCursor(Qt.PointingHandCursor)
|
||||||
|
else:
|
||||||
|
self.viewport().setCursor(Qt.IBeamCursor)
|
||||||
|
super().mouseMoveEvent(e)
|
||||||
|
|
||||||
|
def keyPressEvent(self, e):
|
||||||
|
key = e.key()
|
||||||
|
|
||||||
|
# Pre-insert: stop link/format bleed for “word boundary” keys
|
||||||
|
if key in (Qt.Key_Space, Qt.Key_Tab):
|
||||||
|
self._break_anchor_for_next_char()
|
||||||
|
return super().keyPressEvent(e)
|
||||||
|
|
||||||
|
if key in (Qt.Key_Return, Qt.Key_Enter):
|
||||||
|
c = self.textCursor()
|
||||||
|
# If we're on an empty line inside a code frame, consume Enter and jump out
|
||||||
|
if c.block().length() == 1:
|
||||||
|
frame = self._find_code_frame(c)
|
||||||
|
if frame:
|
||||||
|
out = QTextCursor(self.document())
|
||||||
|
out.setPosition(frame.lastPosition()) # after the frame's contents
|
||||||
|
self.setTextCursor(out)
|
||||||
|
super().insertPlainText("\n") # start a normal paragraph
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise default handling
|
||||||
|
return super().keyPressEvent(e)
|
||||||
|
|
||||||
|
def _clear_insertion_char_format(self):
|
||||||
|
"""Reset inline typing format (keeps lists, alignment, margins, etc.)."""
|
||||||
|
nf = QTextCharFormat()
|
||||||
|
self.setCurrentCharFormat(nf)
|
||||||
|
|
||||||
|
def _break_anchor_for_next_char(self):
|
||||||
|
c = self.textCursor()
|
||||||
|
fmt = c.charFormat()
|
||||||
|
if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
|
||||||
|
# clone, then strip just the link-specific bits so the next char is plain text
|
||||||
|
nf = QTextCharFormat(fmt)
|
||||||
|
nf.setAnchor(False)
|
||||||
|
nf.setFontUnderline(False)
|
||||||
|
nf.clearForeground()
|
||||||
|
try:
|
||||||
|
nf.setAnchorHref("")
|
||||||
|
except AttributeError:
|
||||||
|
nf.setAnchorNames([])
|
||||||
|
self.setCurrentCharFormat(nf)
|
||||||
|
|
||||||
def merge_on_sel(self, fmt):
|
def merge_on_sel(self, fmt):
|
||||||
"""
|
"""
|
||||||
Sets the styling on the selected characters.
|
Sets the styling on the selected characters.
|
||||||
|
|
@ -28,9 +182,15 @@ class Editor(QTextEdit):
|
||||||
cursor.mergeCharFormat(fmt)
|
cursor.mergeCharFormat(fmt)
|
||||||
self.mergeCurrentCharFormat(fmt)
|
self.mergeCurrentCharFormat(fmt)
|
||||||
|
|
||||||
@Slot(QFont.Weight)
|
@Slot()
|
||||||
def apply_weight(self, weight):
|
def apply_weight(self):
|
||||||
|
cur = self.currentCharFormat()
|
||||||
fmt = QTextCharFormat()
|
fmt = QTextCharFormat()
|
||||||
|
weight = (
|
||||||
|
QFont.Weight.Normal
|
||||||
|
if cur.fontWeight() == QFont.Weight.Bold
|
||||||
|
else QFont.Weight.Bold
|
||||||
|
)
|
||||||
fmt.setFontWeight(weight)
|
fmt.setFontWeight(weight)
|
||||||
self.merge_on_sel(fmt)
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
|
@ -59,34 +219,50 @@ class Editor(QTextEdit):
|
||||||
def apply_code(self):
|
def apply_code(self):
|
||||||
c = self.textCursor()
|
c = self.textCursor()
|
||||||
if not c.hasSelection():
|
if not c.hasSelection():
|
||||||
c.select(c.SelectionType.BlockUnderCursor)
|
c.select(QTextCursor.BlockUnderCursor)
|
||||||
|
|
||||||
|
# Wrap the selection in a single frame (no per-block padding/margins).
|
||||||
|
ff = QTextFrameFormat()
|
||||||
|
ff.setBackground(self._CODE_BG)
|
||||||
|
ff.setPadding(6) # visual padding for the WHOLE block
|
||||||
|
ff.setBorder(0)
|
||||||
|
ff.setLeftMargin(0)
|
||||||
|
ff.setRightMargin(0)
|
||||||
|
ff.setTopMargin(0)
|
||||||
|
ff.setBottomMargin(0)
|
||||||
|
ff.setProperty(self._CODE_FRAME_PROP, True)
|
||||||
|
|
||||||
|
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
|
|
||||||
|
c.beginEditBlock()
|
||||||
|
try:
|
||||||
|
c.insertFrame(ff) # with a selection, this wraps the selection
|
||||||
|
|
||||||
|
# Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
|
||||||
|
frame = self._find_code_frame(c)
|
||||||
|
bc = QTextCursor(self.document())
|
||||||
|
bc.setPosition(frame.firstPosition())
|
||||||
|
|
||||||
|
while bc.position() < frame.lastPosition():
|
||||||
|
bc.select(QTextCursor.BlockUnderCursor)
|
||||||
|
|
||||||
bf = QTextBlockFormat()
|
bf = QTextBlockFormat()
|
||||||
|
bf.setTopMargin(0)
|
||||||
|
bf.setBottomMargin(0)
|
||||||
bf.setLeftMargin(12)
|
bf.setLeftMargin(12)
|
||||||
bf.setRightMargin(12)
|
bf.setRightMargin(12)
|
||||||
bf.setTopMargin(6)
|
|
||||||
bf.setBottomMargin(6)
|
|
||||||
bf.setBackground(QColor(245, 245, 245))
|
|
||||||
bf.setNonBreakableLines(True)
|
bf.setNonBreakableLines(True)
|
||||||
|
|
||||||
cf = QTextCharFormat()
|
cf = QTextCharFormat()
|
||||||
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
||||||
cf.setFont(mono)
|
cf.setFont(mono)
|
||||||
cf.setFontFixedPitch(True)
|
cf.setFontFixedPitch(True)
|
||||||
|
|
||||||
# If the current block already looks like a code block, remove styling
|
bc.mergeBlockFormat(bf)
|
||||||
cur_bf = c.blockFormat()
|
bc.mergeBlockCharFormat(cf)
|
||||||
is_code = (
|
|
||||||
cur_bf.nonBreakableLines()
|
|
||||||
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
|
|
||||||
)
|
|
||||||
if is_code:
|
|
||||||
# clear: margins/background/wrapping
|
|
||||||
bf = QTextBlockFormat()
|
|
||||||
cf = QTextCharFormat()
|
|
||||||
|
|
||||||
c.mergeBlockFormat(bf)
|
bc.setPosition(bc.block().position() + bc.block().length())
|
||||||
c.mergeBlockCharFormat(cf)
|
finally:
|
||||||
|
c.endEditBlock()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
def apply_heading(self, size):
|
def apply_heading(self, size):
|
||||||
|
|
|
||||||
188
bouquin/history_dialog.py
Normal file
188
bouquin/history_dialog.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib, re, html as _html
|
||||||
|
from datetime import datetime
|
||||||
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QPushButton,
|
||||||
|
QMessageBox,
|
||||||
|
QTextBrowser,
|
||||||
|
QTabWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_text(s: str) -> str:
|
||||||
|
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
|
||||||
|
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"<[^>]+>")
|
||||||
|
MULTINL_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
s = STYLE_SCRIPT_RE.sub("", s)
|
||||||
|
s = COMMENT_RE.sub("", s)
|
||||||
|
s = BR_RE.sub("\n", s)
|
||||||
|
s = BLOCK_END_RE.sub("\n", s)
|
||||||
|
s = TAG_RE.sub("", s)
|
||||||
|
s = _html.unescape(s)
|
||||||
|
s = MULTINL_RE.sub("\n\n", s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
|
||||||
|
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
||||||
|
a = _html_to_text(old_html).splitlines()
|
||||||
|
b = _html_to_text(new_html).splitlines()
|
||||||
|
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
||||||
|
lines = []
|
||||||
|
for line in ud:
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
lines.append(
|
||||||
|
f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
|
||||||
|
)
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
lines.append(
|
||||||
|
f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
|
||||||
|
)
|
||||||
|
elif line.startswith("@@"):
|
||||||
|
lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
|
||||||
|
else:
|
||||||
|
lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
|
||||||
|
css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
|
||||||
|
return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryDialog(QDialog):
|
||||||
|
"""Show versions for a date, preview, diff, and allow revert."""
|
||||||
|
|
||||||
|
def __init__(self, db, date_iso: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(f"History — {date_iso}")
|
||||||
|
self._db = db
|
||||||
|
self._date = date_iso
|
||||||
|
self._versions = [] # list[dict] from DB
|
||||||
|
self._current_id = None # id of current
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Top: list of versions
|
||||||
|
top = QHBoxLayout()
|
||||||
|
self.list = QListWidget()
|
||||||
|
self.list.setMinimumSize(500, 650)
|
||||||
|
self.list.currentItemChanged.connect(self._on_select)
|
||||||
|
top.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
# Right: tabs (Preview / Diff)
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.preview = QTextBrowser()
|
||||||
|
self.preview.setOpenExternalLinks(True)
|
||||||
|
self.diff = QTextBrowser()
|
||||||
|
self.diff.setOpenExternalLinks(False)
|
||||||
|
self.tabs.addTab(self.preview, "Preview")
|
||||||
|
self.tabs.addTab(self.diff, "Diff")
|
||||||
|
self.tabs.setMinimumSize(500, 650)
|
||||||
|
top.addWidget(self.tabs, 2)
|
||||||
|
|
||||||
|
root.addLayout(top)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addStretch(1)
|
||||||
|
self.btn_revert = QPushButton("Revert to Selected")
|
||||||
|
self.btn_revert.clicked.connect(self._revert)
|
||||||
|
self.btn_close = QPushButton("Close")
|
||||||
|
self.btn_close.clicked.connect(self.reject)
|
||||||
|
row.addWidget(self.btn_revert)
|
||||||
|
row.addWidget(self.btn_close)
|
||||||
|
root.addLayout(row)
|
||||||
|
|
||||||
|
self._load_versions()
|
||||||
|
|
||||||
|
# --- Data/UX helpers ---
|
||||||
|
def _fmt_local(self, iso_utc: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert UTC in the database to user's local tz
|
||||||
|
"""
|
||||||
|
dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
|
||||||
|
local = dt.astimezone()
|
||||||
|
return local.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
|
||||||
|
def _load_versions(self):
|
||||||
|
self._versions = self._db.list_versions(
|
||||||
|
self._date
|
||||||
|
) # [{id,version_no,created_at,note,is_current}]
|
||||||
|
self._current_id = next(
|
||||||
|
(v["id"] for v in self._versions if v["is_current"]), None
|
||||||
|
)
|
||||||
|
self.list.clear()
|
||||||
|
for v in self._versions:
|
||||||
|
label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}"
|
||||||
|
if v.get("note"):
|
||||||
|
label += f" · {v['note']}"
|
||||||
|
if v["is_current"]:
|
||||||
|
label += " **(current)**"
|
||||||
|
it = QListWidgetItem(label)
|
||||||
|
it.setData(Qt.UserRole, v["id"])
|
||||||
|
self.list.addItem(it)
|
||||||
|
# select the first non-current if available, else current
|
||||||
|
idx = 0
|
||||||
|
for i, v in enumerate(self._versions):
|
||||||
|
if not v["is_current"]:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
if self.list.count():
|
||||||
|
self.list.setCurrentRow(idx)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _on_select(self):
|
||||||
|
item = self.list.currentItem()
|
||||||
|
if not item:
|
||||||
|
self.preview.clear()
|
||||||
|
self.diff.clear()
|
||||||
|
self.btn_revert.setEnabled(False)
|
||||||
|
return
|
||||||
|
sel_id = item.data(Qt.UserRole)
|
||||||
|
# Preview selected as HTML
|
||||||
|
sel = self._db.get_version(version_id=sel_id)
|
||||||
|
self.preview.setHtml(sel["content"])
|
||||||
|
# Diff vs current (textual diff)
|
||||||
|
cur = self._db.get_version(version_id=self._current_id)
|
||||||
|
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||||
|
# Enable revert only if selecting a non-current
|
||||||
|
self.btn_revert.setEnabled(sel_id != self._current_id)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _revert(self):
|
||||||
|
item = self.list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
sel_id = item.data(Qt.UserRole)
|
||||||
|
if sel_id == self._current_id:
|
||||||
|
return
|
||||||
|
sel = self._db.get_version(version_id=sel_id)
|
||||||
|
vno = sel["version_no"]
|
||||||
|
# Confirm
|
||||||
|
if (
|
||||||
|
QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Revert",
|
||||||
|
f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
!= QMessageBox.Yes
|
||||||
|
):
|
||||||
|
return
|
||||||
|
# Flip head pointer
|
||||||
|
try:
|
||||||
|
self._db.revert_to_version(self._date, version_id=sel_id)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Revert failed", str(e))
|
||||||
|
return
|
||||||
|
QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
|
||||||
|
self.accept() # let the caller refresh the editor
|
||||||
|
|
@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent=None,
|
parent=None,
|
||||||
title: str = "Unlock database",
|
title: str = "Enter key",
|
||||||
message: str = "Enter SQLCipher key",
|
message: str = "Enter key",
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, QTimer, Qt
|
from pathlib import Path
|
||||||
|
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
|
QCursor,
|
||||||
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
|
QGuiApplication,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QFileDialog,
|
||||||
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
|
@ -21,13 +29,70 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .editor import Editor
|
from .editor import Editor
|
||||||
|
from .history_dialog import HistoryDialog
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
|
from .save_dialog import SaveDialog
|
||||||
from .search import Search
|
from .search import Search
|
||||||
from .settings import APP_NAME, load_db_config, save_db_config
|
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||||
from .settings_dialog import SettingsDialog
|
from .settings_dialog import SettingsDialog
|
||||||
from .toolbar import ToolBar
|
from .toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
|
class _LockOverlay(QWidget):
|
||||||
|
def __init__(self, parent: QWidget, on_unlock: callable):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("LockOverlay")
|
||||||
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
|
self.setGeometry(parent.rect())
|
||||||
|
|
||||||
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
|
#LockOverlay { background-color: #ccc; }
|
||||||
|
#LockOverlay QLabel { color: #fff; font-size: 18px; }
|
||||||
|
#LockOverlay QPushButton {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
color: #000;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#LockOverlay QPushButton:hover { background-color: #ffffff; }
|
||||||
|
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
msg = QLabel("Locked due to inactivity")
|
||||||
|
msg.setAlignment(Qt.AlignCenter)
|
||||||
|
|
||||||
|
self._btn = QPushButton("Unlock")
|
||||||
|
self._btn.setFixedWidth(200)
|
||||||
|
self._btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
self._btn.setAutoDefault(True)
|
||||||
|
self._btn.setDefault(True)
|
||||||
|
self._btn.clicked.connect(on_unlock)
|
||||||
|
|
||||||
|
lay.addWidget(msg, 0, Qt.AlignCenter)
|
||||||
|
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
||||||
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
self.hide() # start hidden
|
||||||
|
|
||||||
|
# keep overlay sized with its parent
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
||||||
|
self.setGeometry(obj.rect())
|
||||||
|
return False
|
||||||
|
|
||||||
|
def showEvent(self, e):
|
||||||
|
super().showEvent(e)
|
||||||
|
self._btn.setFocus()
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -35,9 +100,18 @@ class MainWindow(QMainWindow):
|
||||||
self.setMinimumSize(1000, 650)
|
self.setMinimumSize(1000, 650)
|
||||||
|
|
||||||
self.cfg = load_db_config()
|
self.cfg = load_db_config()
|
||||||
# Always prompt for the key (we never store it)
|
if not os.path.exists(self.cfg.path):
|
||||||
if not self._prompt_for_key_until_valid():
|
# Fresh database/first time use, so guide the user re: setting a key
|
||||||
|
first_time = True
|
||||||
|
else:
|
||||||
|
first_time = False
|
||||||
|
|
||||||
|
# Prompt for the key unless it is found in config
|
||||||
|
if not self.cfg.key:
|
||||||
|
if not self._prompt_for_key_until_valid(first_time):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
self._try_connect()
|
||||||
|
|
||||||
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
||||||
self.calendar = QCalendarWidget()
|
self.calendar = QCalendarWidget()
|
||||||
|
|
@ -62,18 +136,19 @@ class MainWindow(QMainWindow):
|
||||||
self.editor = Editor()
|
self.editor = Editor()
|
||||||
|
|
||||||
# Toolbar for controlling styling
|
# Toolbar for controlling styling
|
||||||
tb = ToolBar()
|
self.toolBar = ToolBar()
|
||||||
self.addToolBar(tb)
|
self.addToolBar(self.toolBar)
|
||||||
# Wire toolbar intents to editor methods
|
# Wire toolbar intents to editor methods
|
||||||
tb.boldRequested.connect(self.editor.apply_weight)
|
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
||||||
tb.italicRequested.connect(self.editor.apply_italic)
|
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
||||||
tb.underlineRequested.connect(self.editor.apply_underline)
|
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
||||||
tb.strikeRequested.connect(self.editor.apply_strikethrough)
|
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
||||||
tb.codeRequested.connect(self.editor.apply_code)
|
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
||||||
tb.headingRequested.connect(self.editor.apply_heading)
|
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
||||||
tb.bulletsRequested.connect(self.editor.toggle_bullets)
|
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
||||||
tb.numbersRequested.connect(self.editor.toggle_numbers)
|
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
||||||
tb.alignRequested.connect(self.editor.setAlignment)
|
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
||||||
|
self.toolBar.historyRequested.connect(self._open_history)
|
||||||
|
|
||||||
split = QSplitter()
|
split = QSplitter()
|
||||||
split.addWidget(left_panel)
|
split.addWidget(left_panel)
|
||||||
|
|
@ -85,20 +160,47 @@ class MainWindow(QMainWindow):
|
||||||
lay.addWidget(split)
|
lay.addWidget(split)
|
||||||
self.setCentralWidget(container)
|
self.setCentralWidget(container)
|
||||||
|
|
||||||
|
# Idle lock setup
|
||||||
|
self._idle_timer = QTimer(self)
|
||||||
|
self._idle_timer.setSingleShot(True)
|
||||||
|
self._idle_timer.timeout.connect(self._enter_lock)
|
||||||
|
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
# full-window overlay that sits on top of the central widget
|
||||||
|
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
||||||
|
self.centralWidget().installEventFilter(self._lock_overlay)
|
||||||
|
|
||||||
|
self._locked = False
|
||||||
|
|
||||||
|
# reset idle timer on any key press anywhere in the app
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
# Status bar for feedback
|
# Status bar for feedback
|
||||||
self.statusBar().showMessage("Ready", 800)
|
self.statusBar().showMessage("Ready", 800)
|
||||||
|
|
||||||
# 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 a version", 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_history = QAction("History", self)
|
||||||
act_settings.setShortcut("Ctrl+E")
|
act_history.setShortcut("Ctrl+H")
|
||||||
|
act_history.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
act_history.triggered.connect(self._open_history)
|
||||||
|
file_menu.addAction(act_history)
|
||||||
|
act_settings = QAction("Settin&gs", self)
|
||||||
|
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")
|
||||||
|
|
@ -128,6 +230,21 @@ class MainWindow(QMainWindow):
|
||||||
nav_menu.addAction(act_today)
|
nav_menu.addAction(act_today)
|
||||||
self.addAction(act_today)
|
self.addAction(act_today)
|
||||||
|
|
||||||
|
# Help menu with drop-down
|
||||||
|
help_menu = mb.addMenu("&Help")
|
||||||
|
act_docs = QAction("Documentation", self)
|
||||||
|
act_docs.setShortcut("Ctrl+D")
|
||||||
|
act_docs.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
act_docs.triggered.connect(self._open_docs)
|
||||||
|
help_menu.addAction(act_docs)
|
||||||
|
self.addAction(act_docs)
|
||||||
|
act_bugs = QAction("Report a bug", self)
|
||||||
|
act_bugs.setShortcut("Ctrl+R")
|
||||||
|
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
act_bugs.triggered.connect(self._open_bugs)
|
||||||
|
help_menu.addAction(act_bugs)
|
||||||
|
self.addAction(act_bugs)
|
||||||
|
|
||||||
# Autosave
|
# Autosave
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
self._save_timer = QTimer(self)
|
self._save_timer = QTimer(self)
|
||||||
|
|
@ -139,6 +256,10 @@ class MainWindow(QMainWindow):
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
|
# Restore window position from settings
|
||||||
|
self.settings = QSettings(APP_ORG, APP_NAME)
|
||||||
|
self._restore_window_position()
|
||||||
|
|
||||||
def _try_connect(self) -> bool:
|
def _try_connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Try to connect to the database.
|
Try to connect to the database.
|
||||||
|
|
@ -155,12 +276,18 @@ class MainWindow(QMainWindow):
|
||||||
return False
|
return False
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
def _prompt_for_key_until_valid(self) -> bool:
|
def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
|
||||||
"""
|
"""
|
||||||
Prompt for the SQLCipher key.
|
Prompt for the SQLCipher key.
|
||||||
"""
|
"""
|
||||||
|
if first_time:
|
||||||
|
title = "Set an encryption key"
|
||||||
|
message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
|
||||||
|
else:
|
||||||
|
title = "Unlock encrypted notebook"
|
||||||
|
message = "Enter your key to unlock the notebook"
|
||||||
while True:
|
while True:
|
||||||
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
dlg = KeyPrompt(self, title, message)
|
||||||
if dlg.exec() != QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
return False
|
return False
|
||||||
self.cfg.key = dlg.key()
|
self.cfg.key = dlg.key()
|
||||||
|
|
@ -206,10 +333,12 @@ class MainWindow(QMainWindow):
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
# track which date the editor currently represents
|
# track which date the editor currently represents
|
||||||
self._active_date_iso = date_iso
|
self._active_date_iso = date_iso
|
||||||
|
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||||
|
self.calendar.setSelectedDate(qd)
|
||||||
|
|
||||||
def _on_text_changed(self):
|
def _on_text_changed(self):
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self._save_timer.start(1200) # autosave after idle
|
self._save_timer.start(10000) # autosave after idle
|
||||||
|
|
||||||
def _adjust_day(self, delta: int):
|
def _adjust_day(self, delta: int):
|
||||||
"""Move selection by delta days (negative for previous)."""
|
"""Move selection by delta days (negative for previous)."""
|
||||||
|
|
@ -237,7 +366,7 @@ class MainWindow(QMainWindow):
|
||||||
# Now load the newly selected date
|
# Now load the newly selected date
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
|
|
||||||
def _save_date(self, date_iso: str, explicit: bool = False):
|
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
||||||
"""
|
"""
|
||||||
Save editor contents into the given date. Shows status on success.
|
Save editor contents into the given date. Shows status on success.
|
||||||
explicit=True means user invoked Save: show feedback even if nothing changed.
|
explicit=True means user invoked Save: show feedback even if nothing changed.
|
||||||
|
|
@ -246,7 +375,7 @@ class MainWindow(QMainWindow):
|
||||||
return
|
return
|
||||||
text = self.editor.toHtml()
|
text = self.editor.toHtml()
|
||||||
try:
|
try:
|
||||||
self.db.upsert_entry(date_iso, text)
|
self.db.save_new_version(date_iso, text, note)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Save Error", str(e))
|
QMessageBox.critical(self, "Save Error", str(e))
|
||||||
return
|
return
|
||||||
|
|
@ -260,20 +389,57 @@ class MainWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save_current(self, explicit: bool = False):
|
def _save_current(self, explicit: bool = False):
|
||||||
|
try:
|
||||||
|
self._save_timer.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if explicit:
|
||||||
|
# Prompt for a note
|
||||||
|
dlg = SaveDialog(self)
|
||||||
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
note = dlg.note_text()
|
||||||
|
else:
|
||||||
|
note = "autosave"
|
||||||
# Delegate to _save_date for the currently selected date
|
# Delegate to _save_date for the currently selected date
|
||||||
self._save_date(self._current_date_iso(), explicit)
|
self._save_date(self._current_date_iso(), explicit, note)
|
||||||
|
try:
|
||||||
|
self._save_timer.start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _open_history(self):
|
||||||
|
date_iso = self._current_date_iso()
|
||||||
|
dlg = HistoryDialog(self.db, date_iso, self)
|
||||||
|
if dlg.exec() == QDialog.Accepted:
|
||||||
|
# refresh editor + calendar (head pointer may have changed)
|
||||||
|
self._load_selected_date(date_iso)
|
||||||
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
|
# ----------- Settings handler ------------#
|
||||||
def _open_settings(self):
|
def _open_settings(self):
|
||||||
dlg = SettingsDialog(self.cfg, self.db, self)
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
||||||
if dlg.exec() == QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
new_cfg = dlg.config
|
new_cfg = dlg.config
|
||||||
if new_cfg.path != self.cfg.path:
|
old_path = self.cfg.path
|
||||||
# Save the new path to the notebook
|
|
||||||
|
# Update in-memory config from the dialog
|
||||||
self.cfg.path = new_cfg.path
|
self.cfg.path = new_cfg.path
|
||||||
|
self.cfg.key = new_cfg.key
|
||||||
|
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
||||||
|
|
||||||
|
# Persist once
|
||||||
save_db_config(self.cfg)
|
save_db_config(self.cfg)
|
||||||
|
|
||||||
|
# Apply idle setting immediately (restart the timer with new interval if it changed)
|
||||||
|
self._apply_idle_minutes(self.cfg.idle_minutes)
|
||||||
|
|
||||||
|
# If the DB path changed, reconnect
|
||||||
|
if self.cfg.path != old_path:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
# Prompt again for the key for the new path
|
if not self._prompt_for_key_until_valid(first_time=False):
|
||||||
if not self._prompt_for_key_until_valid():
|
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, "Reopen failed", "Could not unlock database at new path."
|
self, "Reopen failed", "Could not unlock database at new path."
|
||||||
)
|
)
|
||||||
|
|
@ -281,8 +447,167 @@ class MainWindow(QMainWindow):
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
|
# ------------ Window positioning --------------- #
|
||||||
|
def _restore_window_position(self):
|
||||||
|
geom = self.settings.value("main/geometry", None)
|
||||||
|
state = self.settings.value("main/windowState", None)
|
||||||
|
was_max = self.settings.value("main/maximized", False, type=bool)
|
||||||
|
|
||||||
|
if geom is not None:
|
||||||
|
self.restoreGeometry(geom)
|
||||||
|
if state is not None:
|
||||||
|
self.restoreState(state)
|
||||||
|
if not self._rect_on_any_screen(self.frameGeometry()):
|
||||||
|
self._move_to_cursor_screen_center()
|
||||||
|
else:
|
||||||
|
# First run: place window on the screen where the mouse cursor is.
|
||||||
|
self._move_to_cursor_screen_center()
|
||||||
|
|
||||||
|
# If it was maximized, do that AFTER the window exists in the event loop.
|
||||||
|
if was_max:
|
||||||
|
QTimer.singleShot(0, self.showMaximized)
|
||||||
|
|
||||||
|
def _rect_on_any_screen(self, rect):
|
||||||
|
for sc in QGuiApplication.screens():
|
||||||
|
if sc.availableGeometry().intersects(rect):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _move_to_cursor_screen_center(self):
|
||||||
|
screen = (
|
||||||
|
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
|
||||||
|
)
|
||||||
|
r = screen.availableGeometry()
|
||||||
|
# Center the window in that screen’s available area
|
||||||
|
self.move(r.center() - self.rect().center())
|
||||||
|
|
||||||
|
# ----------------- Export handler ----------------- #
|
||||||
|
@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 _open_docs(self):
|
||||||
|
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
||||||
|
url = QUrl.fromUserInput(url_str)
|
||||||
|
if not QDesktopServices.openUrl(url):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_bugs(self):
|
||||||
|
url_str = "https://nr.mig5.net/forms/mig5/contact"
|
||||||
|
url = QUrl.fromUserInput(url_str)
|
||||||
|
if not QDesktopServices.openUrl(url):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idle handlers
|
||||||
|
def _apply_idle_minutes(self, minutes: int):
|
||||||
|
minutes = max(0, int(minutes))
|
||||||
|
if not hasattr(self, "_idle_timer"):
|
||||||
|
return
|
||||||
|
if minutes == 0:
|
||||||
|
self._idle_timer.stop()
|
||||||
|
# If you’re currently locked, unlock when user disables the timer:
|
||||||
|
if getattr(self, "_locked", False):
|
||||||
|
try:
|
||||||
|
self._locked = False
|
||||||
|
if hasattr(self, "_lock_overlay"):
|
||||||
|
self._lock_overlay.hide()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._idle_timer.setInterval(minutes * 60 * 1000)
|
||||||
|
if not getattr(self, "_locked", False):
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if event.type() == QEvent.KeyPress and not self._locked:
|
||||||
|
self._idle_timer.start()
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
def _enter_lock(self):
|
||||||
|
if self._locked:
|
||||||
|
return
|
||||||
|
self._locked = True
|
||||||
|
if self.menuBar():
|
||||||
|
self.menuBar().setEnabled(False)
|
||||||
|
if self.statusBar():
|
||||||
|
self.statusBar().setEnabled(False)
|
||||||
|
tb = getattr(self, "toolBar", None)
|
||||||
|
if tb:
|
||||||
|
tb.setEnabled(False)
|
||||||
|
self._lock_overlay.show()
|
||||||
|
self._lock_overlay.raise_()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _on_unlock_clicked(self):
|
||||||
|
try:
|
||||||
|
ok = self._prompt_for_key_until_valid(first_time=False)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Unlock failed", str(e))
|
||||||
|
return
|
||||||
|
if ok:
|
||||||
|
self._locked = False
|
||||||
|
self._lock_overlay.hide()
|
||||||
|
if self.menuBar():
|
||||||
|
self.menuBar().setEnabled(True)
|
||||||
|
if self.statusBar():
|
||||||
|
self.statusBar().setEnabled(True)
|
||||||
|
tb = getattr(self, "toolBar", None)
|
||||||
|
if tb:
|
||||||
|
tb.setEnabled(True)
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
# Close app handler - save window position and database
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
try:
|
try:
|
||||||
|
# Save window position
|
||||||
|
self.settings.setValue("main/geometry", self.saveGeometry())
|
||||||
|
self.settings.setValue("main/windowState", self.saveState())
|
||||||
|
self.settings.setValue("main/maximized", self.isMaximized())
|
||||||
|
# Ensure we save any last pending edits to the db
|
||||||
self._save_current()
|
self._save_current()
|
||||||
self.db.close()
|
self.db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
35
bouquin/save_dialog.py
Normal file
35
bouquin/save_dialog.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QVBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QDialogButtonBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveDialog(QDialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
title: str = "Enter a name for this version",
|
||||||
|
message: str = "Enter a name for this version?",
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
v.addWidget(QLabel(message))
|
||||||
|
self.note = QLineEdit()
|
||||||
|
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
self.note.setText(f"New version I saved at {now}")
|
||||||
|
v.addWidget(self.note)
|
||||||
|
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
bb.accepted.connect(self.accept)
|
||||||
|
bb.rejected.connect(self.reject)
|
||||||
|
v.addWidget(bb)
|
||||||
|
|
||||||
|
def note_text(self) -> str:
|
||||||
|
return self.note.text()
|
||||||
|
|
@ -80,7 +80,7 @@ class Search(QWidget):
|
||||||
for date_str, content in rows:
|
for date_str, content in rows:
|
||||||
# Build an HTML fragment around the match and whether to show ellipses
|
# Build an HTML fragment around the match and whether to show ellipses
|
||||||
frag_html, left_ell, right_ell = self._make_html_snippet(
|
frag_html, left_ell, right_ell = self._make_html_snippet(
|
||||||
content, query, radius=60, maxlen=180
|
content, query, radius=30, maxlen=90
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||||
|
|
@ -112,7 +112,7 @@ class Search(QWidget):
|
||||||
preview = QLabel()
|
preview = QLabel()
|
||||||
preview.setTextFormat(Qt.TextFormat.RichText)
|
preview.setTextFormat(Qt.TextFormat.RichText)
|
||||||
preview.setWordWrap(True)
|
preview.setWordWrap(True)
|
||||||
preview.setOpenExternalLinks(True) # keep links in your HTML clickable
|
preview.setOpenExternalLinks(True)
|
||||||
preview.setText(
|
preview.setText(
|
||||||
frag_html
|
frag_html
|
||||||
if frag_html
|
if frag_html
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,13 @@ def get_settings() -> QSettings:
|
||||||
def load_db_config() -> DBConfig:
|
def load_db_config() -> DBConfig:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
path = Path(s.value("db/path", str(default_db_path())))
|
path = Path(s.value("db/path", str(default_db_path())))
|
||||||
return DBConfig(path=path, key="")
|
key = s.value("db/key", "")
|
||||||
|
idle = s.value("db/idle_minutes", 15, type=int)
|
||||||
|
return DBConfig(path=path, key=key, idle_minutes=idle)
|
||||||
|
|
||||||
|
|
||||||
def save_db_config(cfg: DBConfig) -> None:
|
def save_db_config(cfg: DBConfig) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
s.setValue("db/path", str(cfg.path))
|
s.setValue("db/path", str(cfg.path))
|
||||||
|
s.setValue("db/key", str(cfg.key))
|
||||||
|
s.setValue("db/idle_minutes", str(cfg.idle_minutes))
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QGroupBox,
|
||||||
|
QLabel,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
|
|
@ -13,11 +17,15 @@ from PySide6.QtWidgets import (
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
|
||||||
|
|
||||||
from .db import DBConfig, DBManager
|
from .db import DBConfig, DBManager
|
||||||
from .settings import save_db_config
|
from .settings import load_db_config, save_db_config
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,10 +35,11 @@ class SettingsDialog(QDialog):
|
||||||
self.setWindowTitle("Settings")
|
self.setWindowTitle("Settings")
|
||||||
self._cfg = DBConfig(path=cfg.path, key="")
|
self._cfg = DBConfig(path=cfg.path, key="")
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.key = ""
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||||
self.setMinimumWidth(520)
|
self.setMinimumWidth(560)
|
||||||
self.setSizeGripEnabled(True)
|
self.setSizeGripEnabled(True)
|
||||||
|
|
||||||
self.path_edit = QLineEdit(str(self._cfg.path))
|
self.path_edit = QLineEdit(str(self._cfg.path))
|
||||||
|
|
@ -47,18 +56,94 @@ class SettingsDialog(QDialog):
|
||||||
h.setStretch(1, 0)
|
h.setStretch(1, 0)
|
||||||
form.addRow("Database path", path_row)
|
form.addRow("Database path", path_row)
|
||||||
|
|
||||||
|
# Encryption settings
|
||||||
|
enc_group = QGroupBox("Encryption and Privacy")
|
||||||
|
enc = QVBoxLayout(enc_group)
|
||||||
|
enc.setContentsMargins(12, 8, 12, 12)
|
||||||
|
enc.setSpacing(6)
|
||||||
|
|
||||||
|
# Checkbox to remember key
|
||||||
|
self.save_key_btn = QCheckBox("Remember key")
|
||||||
|
current_settings = load_db_config()
|
||||||
|
self.key = current_settings.key or ""
|
||||||
|
self.save_key_btn.setChecked(bool(self.key))
|
||||||
|
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
||||||
|
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
||||||
|
|
||||||
|
# Explanation for remembering key
|
||||||
|
self.save_key_label = QLabel(
|
||||||
|
"If you don't want to be prompted for your encryption key, check this to remember it. "
|
||||||
|
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
|
||||||
|
)
|
||||||
|
self.save_key_label.setWordWrap(True)
|
||||||
|
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||||
|
# make it look secondary
|
||||||
|
pal = self.save_key_label.palette()
|
||||||
|
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
|
||||||
|
self.save_key_label.setPalette(pal)
|
||||||
|
|
||||||
|
exp_row = QHBoxLayout()
|
||||||
|
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
|
||||||
|
exp_row.addWidget(self.save_key_label)
|
||||||
|
enc.addLayout(exp_row)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
enc.addWidget(line)
|
||||||
|
|
||||||
|
self.idle_spin = QSpinBox()
|
||||||
|
self.idle_spin.setRange(0, 240)
|
||||||
|
self.idle_spin.setSingleStep(1)
|
||||||
|
self.idle_spin.setAccelerated(True)
|
||||||
|
self.idle_spin.setSuffix(" min")
|
||||||
|
self.idle_spin.setSpecialValueText("Never")
|
||||||
|
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
||||||
|
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
||||||
|
# Explanation for idle option (autolock)
|
||||||
|
self.idle_spin_label = QLabel(
|
||||||
|
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
||||||
|
"Set to 0 (never) to never lock."
|
||||||
|
)
|
||||||
|
self.idle_spin_label.setWordWrap(True)
|
||||||
|
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||||
|
# make it look secondary
|
||||||
|
spal = self.idle_spin_label.palette()
|
||||||
|
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
|
||||||
|
self.idle_spin_label.setPalette(spal)
|
||||||
|
|
||||||
|
spin_row = QHBoxLayout()
|
||||||
|
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
||||||
|
spin_row.addWidget(self.idle_spin_label)
|
||||||
|
enc.addLayout(spin_row)
|
||||||
|
|
||||||
|
line2 = QFrame()
|
||||||
|
line2.setFrameShape(QFrame.HLine)
|
||||||
|
line2.setFrameShadow(QFrame.Sunken)
|
||||||
|
enc.addWidget(line2)
|
||||||
|
|
||||||
# Change key button
|
# Change key button
|
||||||
self.rekey_btn = QPushButton("Change key")
|
self.rekey_btn = QPushButton("Change encryption key")
|
||||||
|
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||||
self.rekey_btn.clicked.connect(self._change_key)
|
self.rekey_btn.clicked.connect(self._change_key)
|
||||||
|
|
||||||
|
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
||||||
|
|
||||||
|
# Put the group into the form so it spans the full width nicely
|
||||||
|
form.addRow(enc_group)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self._save)
|
bb.accepted.connect(self._save)
|
||||||
bb.rejected.connect(self.reject)
|
bb.rejected.connect(self.reject)
|
||||||
|
|
||||||
|
# Root layout (adjust margins/spacing a bit)
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
|
v.setContentsMargins(12, 12, 12, 12)
|
||||||
|
v.setSpacing(10)
|
||||||
v.addLayout(form)
|
v.addLayout(form)
|
||||||
v.addWidget(self.rekey_btn)
|
v.addWidget(bb, 0, Qt.AlignRight)
|
||||||
v.addWidget(bb)
|
|
||||||
|
|
||||||
def _browse(self):
|
def _browse(self):
|
||||||
p, _ = QFileDialog.getSaveFileName(
|
p, _ = QFileDialog.getSaveFileName(
|
||||||
|
|
@ -71,16 +156,21 @@ class SettingsDialog(QDialog):
|
||||||
self.path_edit.setText(p)
|
self.path_edit.setText(p)
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
|
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
||||||
|
self._cfg = DBConfig(
|
||||||
|
path=Path(self.path_edit.text()),
|
||||||
|
key=key_to_save,
|
||||||
|
idle_minutes=self.idle_spin.value(),
|
||||||
|
)
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _change_key(self):
|
def _change_key(self):
|
||||||
p1 = KeyPrompt(self, title="Change key", message="Enter new key")
|
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
|
||||||
if p1.exec() != QDialog.Accepted:
|
if p1.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
new_key = p1.key()
|
new_key = p1.key()
|
||||||
p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
|
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
|
||||||
if p2.exec() != QDialog.Accepted:
|
if p2.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
if new_key != p2.key():
|
if new_key != p2.key():
|
||||||
|
|
@ -90,13 +180,30 @@ class SettingsDialog(QDialog):
|
||||||
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
|
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
self.key = new_key
|
||||||
self._db.rekey(new_key)
|
self._db.rekey(new_key)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "Key changed", "The database key was updated."
|
self, "Key changed", "The notebook was re-encrypted with the new key!"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
def save_key_btn_clicked(self, checked: bool):
|
||||||
|
if checked:
|
||||||
|
if not self.key:
|
||||||
|
p1 = KeyPrompt(
|
||||||
|
self, title="Enter your key", message="Enter the encryption key"
|
||||||
|
)
|
||||||
|
if p1.exec() != QDialog.Accepted:
|
||||||
|
self.save_key_btn.blockSignals(True)
|
||||||
|
self.save_key_btn.setChecked(False)
|
||||||
|
self.save_key_btn.blockSignals(False)
|
||||||
|
return
|
||||||
|
self.key = p1.key() or ""
|
||||||
|
else:
|
||||||
|
self.key = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self) -> DBConfig:
|
def config(self) -> DBConfig:
|
||||||
return self._cfg
|
return self._cfg
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Signal, Qt
|
from PySide6.QtCore import Signal, Qt
|
||||||
from PySide6.QtGui import QFont, QAction
|
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
|
||||||
from PySide6.QtWidgets import QToolBar
|
from PySide6.QtWidgets import QToolBar
|
||||||
|
|
||||||
|
|
||||||
class ToolBar(QToolBar):
|
class ToolBar(QToolBar):
|
||||||
boldRequested = Signal(QFont.Weight)
|
boldRequested = Signal()
|
||||||
italicRequested = Signal()
|
italicRequested = Signal()
|
||||||
underlineRequested = Signal()
|
underlineRequested = Signal()
|
||||||
strikeRequested = Signal()
|
strikeRequested = Signal()
|
||||||
|
|
@ -15,84 +15,143 @@ class ToolBar(QToolBar):
|
||||||
bulletsRequested = Signal()
|
bulletsRequested = Signal()
|
||||||
numbersRequested = Signal()
|
numbersRequested = Signal()
|
||||||
alignRequested = Signal(Qt.AlignmentFlag)
|
alignRequested = Signal(Qt.AlignmentFlag)
|
||||||
|
historyRequested = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__("Format", parent)
|
super().__init__("Format", parent)
|
||||||
|
self.setObjectName("Format")
|
||||||
|
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||||||
self._build_actions()
|
self._build_actions()
|
||||||
|
self._apply_toolbar_styles()
|
||||||
|
|
||||||
def _build_actions(self):
|
def _build_actions(self):
|
||||||
# Bold
|
self.actBold = QAction("Bold", self)
|
||||||
bold = QAction("Bold", self)
|
self.actBold.setShortcut(QKeySequence.Bold)
|
||||||
bold.setShortcut("Ctrl+B")
|
self.actBold.triggered.connect(self.boldRequested)
|
||||||
bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
|
|
||||||
|
|
||||||
italic = QAction("Italic", self)
|
self.actItalic = QAction("Italic", self)
|
||||||
italic.setShortcut("Ctrl+I")
|
self.actItalic.setShortcut(QKeySequence.Italic)
|
||||||
italic.triggered.connect(self.italicRequested)
|
self.actItalic.triggered.connect(self.italicRequested)
|
||||||
|
|
||||||
underline = QAction("Underline", self)
|
self.actUnderline = QAction("Underline", self)
|
||||||
underline.setShortcut("Ctrl+U")
|
self.actUnderline.setShortcut(QKeySequence.Underline)
|
||||||
underline.triggered.connect(self.underlineRequested)
|
self.actUnderline.triggered.connect(self.underlineRequested)
|
||||||
|
|
||||||
strike = QAction("Strikethrough", self)
|
self.actStrike = QAction("Strikethrough", self)
|
||||||
strike.setShortcut("Ctrl+-")
|
self.actStrike.setShortcut("Ctrl+-")
|
||||||
strike.triggered.connect(self.strikeRequested)
|
self.actStrike.triggered.connect(self.strikeRequested)
|
||||||
|
|
||||||
code = QAction("<code>", self)
|
self.actCode = QAction("Inline code", self)
|
||||||
code.setShortcut("Ctrl+`")
|
self.actCode.setShortcut("Ctrl+`")
|
||||||
code.triggered.connect(self.codeRequested)
|
self.actCode.triggered.connect(self.codeRequested)
|
||||||
|
|
||||||
# Headings
|
# Headings
|
||||||
h1 = QAction("H1", self)
|
self.actH1 = QAction("Heading 1", self)
|
||||||
h1.setShortcut("Ctrl+1")
|
self.actH2 = QAction("Heading 2", self)
|
||||||
h2 = QAction("H2", self)
|
self.actH3 = QAction("Heading 3", self)
|
||||||
h2.setShortcut("Ctrl+2")
|
self.actNormal = QAction("Normal text", self)
|
||||||
h3 = QAction("H3", self)
|
self.actH1.setShortcut("Ctrl+1")
|
||||||
h3.setShortcut("Ctrl+3")
|
self.actH2.setShortcut("Ctrl+2")
|
||||||
normal = QAction("Normal", self)
|
self.actH3.setShortcut("Ctrl+3")
|
||||||
normal.setShortcut("Ctrl+P")
|
self.actNormal.setShortcut("Ctrl+O")
|
||||||
|
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
|
||||||
h1.triggered.connect(lambda: self.headingRequested.emit(24))
|
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
|
||||||
h2.triggered.connect(lambda: self.headingRequested.emit(18))
|
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
|
||||||
h3.triggered.connect(lambda: self.headingRequested.emit(14))
|
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
|
||||||
normal.triggered.connect(lambda: self.headingRequested.emit(0))
|
|
||||||
|
|
||||||
# Lists
|
# Lists
|
||||||
bullets = QAction("• Bullets", self)
|
self.actBullets = QAction("Bulleted list", self)
|
||||||
bullets.triggered.connect(self.bulletsRequested)
|
self.actBullets.triggered.connect(self.bulletsRequested)
|
||||||
numbers = QAction("1. Numbered", self)
|
self.actNumbers = QAction("Numbered list", self)
|
||||||
numbers.triggered.connect(self.numbersRequested)
|
self.actNumbers.triggered.connect(self.numbersRequested)
|
||||||
|
|
||||||
# Alignment
|
# Alignment
|
||||||
left = QAction("Align Left", self)
|
self.actAlignL = QAction("Align left", self)
|
||||||
center = QAction("Align Center", self)
|
self.actAlignC = QAction("Align center", self)
|
||||||
right = QAction("Align Right", self)
|
self.actAlignR = QAction("Align right", self)
|
||||||
|
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
|
||||||
|
self.actAlignC.triggered.connect(
|
||||||
|
lambda: self.alignRequested.emit(Qt.AlignHCenter)
|
||||||
|
)
|
||||||
|
self.actAlignR.triggered.connect(
|
||||||
|
lambda: self.alignRequested.emit(Qt.AlignRight)
|
||||||
|
)
|
||||||
|
|
||||||
left.triggered.connect(
|
# History button
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
|
self.actHistory = QAction("History", self)
|
||||||
)
|
self.actHistory.triggered.connect(self.historyRequested)
|
||||||
center.triggered.connect(
|
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
|
|
||||||
)
|
|
||||||
right.triggered.connect(
|
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.addActions(
|
self.addActions(
|
||||||
[
|
[
|
||||||
bold,
|
self.actBold,
|
||||||
italic,
|
self.actItalic,
|
||||||
underline,
|
self.actUnderline,
|
||||||
strike,
|
self.actStrike,
|
||||||
code,
|
self.actCode,
|
||||||
h1,
|
self.actH1,
|
||||||
h2,
|
self.actH2,
|
||||||
h3,
|
self.actH3,
|
||||||
normal,
|
self.actNormal,
|
||||||
bullets,
|
self.actBullets,
|
||||||
numbers,
|
self.actNumbers,
|
||||||
left,
|
self.actAlignL,
|
||||||
center,
|
self.actAlignC,
|
||||||
right,
|
self.actAlignR,
|
||||||
|
self.actHistory,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_toolbar_styles(self):
|
||||||
|
self._style_letter_button(self.actBold, "B", bold=True)
|
||||||
|
self._style_letter_button(self.actItalic, "I", italic=True)
|
||||||
|
self._style_letter_button(self.actUnderline, "U", underline=True)
|
||||||
|
self._style_letter_button(self.actStrike, "S", strike=True)
|
||||||
|
|
||||||
|
# Monospace look for code; use a fixed font
|
||||||
|
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
|
self._style_letter_button(self.actCode, "</>", custom_font=code_font)
|
||||||
|
|
||||||
|
# Headings
|
||||||
|
self._style_letter_button(self.actH1, "H1")
|
||||||
|
self._style_letter_button(self.actH2, "H2")
|
||||||
|
self._style_letter_button(self.actH3, "H3")
|
||||||
|
self._style_letter_button(self.actNormal, "N")
|
||||||
|
|
||||||
|
# Lists
|
||||||
|
self._style_letter_button(self.actBullets, "•")
|
||||||
|
self._style_letter_button(self.actNumbers, "1.")
|
||||||
|
|
||||||
|
# Alignment
|
||||||
|
self._style_letter_button(self.actAlignL, "L")
|
||||||
|
self._style_letter_button(self.actAlignC, "C")
|
||||||
|
self._style_letter_button(self.actAlignR, "R")
|
||||||
|
|
||||||
|
# History
|
||||||
|
self._style_letter_button(self.actHistory, "View History")
|
||||||
|
|
||||||
|
def _style_letter_button(
|
||||||
|
self,
|
||||||
|
action: QAction,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
bold: bool = False,
|
||||||
|
italic: bool = False,
|
||||||
|
underline: bool = False,
|
||||||
|
strike: bool = False,
|
||||||
|
custom_font: QFont | None = None,
|
||||||
|
):
|
||||||
|
btn = self.widgetForAction(action)
|
||||||
|
if not btn:
|
||||||
|
return
|
||||||
|
btn.setText(text)
|
||||||
|
f = custom_font if custom_font is not None else QFont(btn.font())
|
||||||
|
if custom_font is None:
|
||||||
|
f.setBold(bold)
|
||||||
|
f.setItalic(italic)
|
||||||
|
f.setUnderline(underline)
|
||||||
|
f.setStrikeOut(strike)
|
||||||
|
btn.setFont(f)
|
||||||
|
|
||||||
|
# Keep accessibility/tooltip readable
|
||||||
|
btn.setToolTip(action.text())
|
||||||
|
btn.setAccessibleName(action.text())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.1.2"
|
version = "0.1.8"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 82 KiB |
Loading…
Add table
Add a link
Reference in a new issue