bouquin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bouquin might be problematic. Click here for more details.
- bouquin/__init__.py +1 -0
- bouquin/__main__.py +4 -0
- bouquin/db.py +92 -0
- bouquin/highlighter.py +112 -0
- bouquin/key_prompt.py +41 -0
- bouquin/main.py +15 -0
- bouquin/main_window.py +245 -0
- bouquin/settings.py +29 -0
- bouquin/settings_dialog.py +72 -0
- bouquin-0.1.0.dist-info/LICENSE +674 -0
- bouquin-0.1.0.dist-info/METADATA +80 -0
- bouquin-0.1.0.dist-info/RECORD +14 -0
- bouquin-0.1.0.dist-info/WHEEL +4 -0
- bouquin-0.1.0.dist-info/entry_points.txt +3 -0
bouquin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import main
|
bouquin/__main__.py
ADDED
bouquin/db.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sqlcipher3 import dbapi2 as sqlite
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DBConfig:
|
|
11
|
+
path: Path
|
|
12
|
+
key: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DBManager:
|
|
16
|
+
def __init__(self, cfg: DBConfig):
|
|
17
|
+
self.cfg = cfg
|
|
18
|
+
self.conn: sqlite.Connection | None = None
|
|
19
|
+
|
|
20
|
+
def connect(self) -> bool:
|
|
21
|
+
# Ensure parent dir exists
|
|
22
|
+
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.conn = sqlite.connect(str(self.cfg.path))
|
|
24
|
+
cur = self.conn.cursor()
|
|
25
|
+
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
|
26
|
+
cur.execute("PRAGMA cipher_compatibility = 4;")
|
|
27
|
+
cur.execute("PRAGMA journal_mode = WAL;")
|
|
28
|
+
self.conn.commit()
|
|
29
|
+
try:
|
|
30
|
+
self._integrity_ok()
|
|
31
|
+
except Exception:
|
|
32
|
+
self.conn.close()
|
|
33
|
+
self.conn = None
|
|
34
|
+
return False
|
|
35
|
+
self._ensure_schema()
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def _integrity_ok(self) -> bool:
|
|
39
|
+
cur = self.conn.cursor()
|
|
40
|
+
cur.execute("PRAGMA cipher_integrity_check;")
|
|
41
|
+
rows = cur.fetchall()
|
|
42
|
+
|
|
43
|
+
# OK
|
|
44
|
+
if not rows:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Not OK
|
|
48
|
+
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
|
49
|
+
raise sqlite.IntegrityError(
|
|
50
|
+
"SQLCipher integrity check failed"
|
|
51
|
+
+ (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _ensure_schema(self) -> None:
|
|
55
|
+
cur = self.conn.cursor()
|
|
56
|
+
cur.execute(
|
|
57
|
+
"""
|
|
58
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
59
|
+
date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
|
|
60
|
+
content TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
"""
|
|
63
|
+
)
|
|
64
|
+
cur.execute("PRAGMA user_version = 1;")
|
|
65
|
+
self.conn.commit()
|
|
66
|
+
|
|
67
|
+
def get_entry(self, date_iso: str) -> str:
|
|
68
|
+
cur = self.conn.cursor()
|
|
69
|
+
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
|
|
70
|
+
row = cur.fetchone()
|
|
71
|
+
return row[0] if row else ""
|
|
72
|
+
|
|
73
|
+
def upsert_entry(self, date_iso: str, content: str) -> None:
|
|
74
|
+
cur = self.conn.cursor()
|
|
75
|
+
cur.execute(
|
|
76
|
+
"""
|
|
77
|
+
INSERT INTO entries(date, content) VALUES(?, ?)
|
|
78
|
+
ON CONFLICT(date) DO UPDATE SET content = excluded.content;
|
|
79
|
+
""",
|
|
80
|
+
(date_iso, content),
|
|
81
|
+
)
|
|
82
|
+
self.conn.commit()
|
|
83
|
+
|
|
84
|
+
def dates_with_content(self) -> list[str]:
|
|
85
|
+
cur = self.conn.cursor()
|
|
86
|
+
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
|
87
|
+
return [r[0] for r in cur.fetchall()]
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
if self.conn is not None:
|
|
91
|
+
self.conn.close()
|
|
92
|
+
self.conn = None
|
bouquin/highlighter.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MarkdownHighlighter(QSyntaxHighlighter):
|
|
8
|
+
ST_NORMAL = 0
|
|
9
|
+
ST_CODE = 1
|
|
10
|
+
|
|
11
|
+
FENCE = re.compile(r"^```")
|
|
12
|
+
|
|
13
|
+
def __init__(self, document):
|
|
14
|
+
super().__init__(document)
|
|
15
|
+
|
|
16
|
+
base_size = document.defaultFont().pointSizeF() or 12.0
|
|
17
|
+
|
|
18
|
+
# Monospace for code
|
|
19
|
+
self.mono = QFont("Monospace")
|
|
20
|
+
self.mono.setStyleHint(QFont.TypeWriter)
|
|
21
|
+
|
|
22
|
+
# Light, high-contrast scheme for code
|
|
23
|
+
self.col_bg = QColor("#eef2f6") # light code bg
|
|
24
|
+
self.col_fg = QColor("#1f2328") # dark text
|
|
25
|
+
|
|
26
|
+
# Formats
|
|
27
|
+
self.fmt_h = [QTextCharFormat() for _ in range(6)]
|
|
28
|
+
for i, f in enumerate(self.fmt_h, start=1):
|
|
29
|
+
f.setFontWeight(QFont.Weight.Bold)
|
|
30
|
+
f.setFontPointSize(base_size + (7 - i))
|
|
31
|
+
self.fmt_bold = QTextCharFormat()
|
|
32
|
+
self.fmt_bold.setFontWeight(QFont.Weight.Bold)
|
|
33
|
+
self.fmt_italic = QTextCharFormat()
|
|
34
|
+
self.fmt_italic.setFontItalic(True)
|
|
35
|
+
self.fmt_quote = QTextCharFormat()
|
|
36
|
+
self.fmt_quote.setForeground(QColor("#6a737d"))
|
|
37
|
+
self.fmt_link = QTextCharFormat()
|
|
38
|
+
self.fmt_link.setFontUnderline(True)
|
|
39
|
+
self.fmt_list = QTextCharFormat()
|
|
40
|
+
self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
|
|
41
|
+
self.fmt_strike = QTextCharFormat()
|
|
42
|
+
self.fmt_strike.setFontStrikeOut(True)
|
|
43
|
+
|
|
44
|
+
# Uniform code style
|
|
45
|
+
self.fmt_code = QTextCharFormat()
|
|
46
|
+
self.fmt_code.setFont(self.mono)
|
|
47
|
+
self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
|
|
48
|
+
self.fmt_code.setBackground(self.col_bg)
|
|
49
|
+
self.fmt_code.setForeground(self.col_fg)
|
|
50
|
+
|
|
51
|
+
# Simple patterns
|
|
52
|
+
self.re_heading = re.compile(r"^(#{1,6}) +.*$")
|
|
53
|
+
self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
|
|
54
|
+
self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
|
|
55
|
+
self.re_strike = re.compile(r"~~(.+?)~~")
|
|
56
|
+
self.re_inline_code = re.compile(r"`([^`]+)`")
|
|
57
|
+
self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
58
|
+
self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
|
|
59
|
+
self.re_quote = re.compile(r"^> ?.*$")
|
|
60
|
+
|
|
61
|
+
def highlightBlock(self, text: str) -> None:
|
|
62
|
+
prev = self.previousBlockState()
|
|
63
|
+
in_code = prev == self.ST_CODE
|
|
64
|
+
|
|
65
|
+
if in_code:
|
|
66
|
+
# Entire line is code
|
|
67
|
+
self.setFormat(0, len(text), self.fmt_code)
|
|
68
|
+
if self.FENCE.match(text):
|
|
69
|
+
self.setCurrentBlockState(self.ST_NORMAL)
|
|
70
|
+
else:
|
|
71
|
+
self.setCurrentBlockState(self.ST_CODE)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Starting/ending a fenced block?
|
|
75
|
+
if self.FENCE.match(text):
|
|
76
|
+
self.setFormat(0, len(text), self.fmt_code)
|
|
77
|
+
self.setCurrentBlockState(self.ST_CODE)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# --- Normal markdown styling ---
|
|
81
|
+
m = self.re_heading.match(text)
|
|
82
|
+
if m:
|
|
83
|
+
level = min(len(m.group(1)), 6)
|
|
84
|
+
self.setFormat(0, len(text), self.fmt_h[level - 1])
|
|
85
|
+
self.setCurrentBlockState(self.ST_NORMAL)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
m = self.re_list.match(text)
|
|
89
|
+
if m:
|
|
90
|
+
self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
|
|
91
|
+
|
|
92
|
+
if self.re_quote.match(text):
|
|
93
|
+
self.setFormat(0, len(text), self.fmt_quote)
|
|
94
|
+
|
|
95
|
+
for m in self.re_inline_code.finditer(text):
|
|
96
|
+
self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
|
|
97
|
+
|
|
98
|
+
for m in self.re_bold.finditer(text):
|
|
99
|
+
self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
|
|
100
|
+
|
|
101
|
+
for m in self.re_italic.finditer(text):
|
|
102
|
+
self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
|
|
103
|
+
|
|
104
|
+
for m in self.re_strike.finditer(text):
|
|
105
|
+
self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
|
|
106
|
+
|
|
107
|
+
for m in self.re_link.finditer(text):
|
|
108
|
+
start = m.start(1) - 1
|
|
109
|
+
length = len(m.group(1)) + 2
|
|
110
|
+
self.setFormat(start, length, self.fmt_link)
|
|
111
|
+
|
|
112
|
+
self.setCurrentBlockState(self.ST_NORMAL)
|
bouquin/key_prompt.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtWidgets import (
|
|
4
|
+
QDialog,
|
|
5
|
+
QVBoxLayout,
|
|
6
|
+
QLabel,
|
|
7
|
+
QLineEdit,
|
|
8
|
+
QPushButton,
|
|
9
|
+
QDialogButtonBox,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KeyPrompt(QDialog):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
parent=None,
|
|
17
|
+
title: str = "Unlock database",
|
|
18
|
+
message: str = "Enter SQLCipher key",
|
|
19
|
+
):
|
|
20
|
+
super().__init__(parent)
|
|
21
|
+
self.setWindowTitle(title)
|
|
22
|
+
v = QVBoxLayout(self)
|
|
23
|
+
v.addWidget(QLabel(message))
|
|
24
|
+
self.edit = QLineEdit()
|
|
25
|
+
self.edit.setEchoMode(QLineEdit.Password)
|
|
26
|
+
v.addWidget(self.edit)
|
|
27
|
+
toggle = QPushButton("Show")
|
|
28
|
+
toggle.setCheckable(True)
|
|
29
|
+
toggle.toggled.connect(
|
|
30
|
+
lambda c: self.edit.setEchoMode(
|
|
31
|
+
QLineEdit.Normal if c else QLineEdit.Password
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
v.addWidget(toggle)
|
|
35
|
+
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
36
|
+
bb.accepted.connect(self.accept)
|
|
37
|
+
bb.rejected.connect(self.reject)
|
|
38
|
+
v.addWidget(bb)
|
|
39
|
+
|
|
40
|
+
def key(self) -> str:
|
|
41
|
+
return self.edit.text()
|
bouquin/main.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from PySide6.QtWidgets import QApplication
|
|
5
|
+
|
|
6
|
+
from .settings import APP_NAME, APP_ORG
|
|
7
|
+
from .main_window import MainWindow
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
app = QApplication(sys.argv)
|
|
12
|
+
app.setApplicationName(APP_NAME)
|
|
13
|
+
app.setOrganizationName(APP_ORG)
|
|
14
|
+
win = MainWindow(); win.show()
|
|
15
|
+
sys.exit(app.exec())
|
bouquin/main_window.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QDate, QTimer, Qt
|
|
6
|
+
from PySide6.QtGui import QAction, QFont, QTextCharFormat
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QDialog,
|
|
9
|
+
QCalendarWidget,
|
|
10
|
+
QMainWindow,
|
|
11
|
+
QMessageBox,
|
|
12
|
+
QPlainTextEdit,
|
|
13
|
+
QSplitter,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
QSizePolicy,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .db import DBManager
|
|
20
|
+
from .settings import APP_NAME, load_db_config, save_db_config
|
|
21
|
+
from .key_prompt import KeyPrompt
|
|
22
|
+
from .highlighter import MarkdownHighlighter
|
|
23
|
+
from .settings_dialog import SettingsDialog
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MainWindow(QMainWindow):
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.setWindowTitle(APP_NAME)
|
|
30
|
+
self.setMinimumSize(1000, 650)
|
|
31
|
+
|
|
32
|
+
self.cfg = load_db_config()
|
|
33
|
+
# Always prompt for the key (we never store it)
|
|
34
|
+
if not self._prompt_for_key_until_valid():
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
|
38
|
+
self.calendar = QCalendarWidget()
|
|
39
|
+
self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
40
|
+
self.calendar.setGridVisible(True)
|
|
41
|
+
self.calendar.selectionChanged.connect(self._on_date_changed)
|
|
42
|
+
|
|
43
|
+
left_panel = QWidget()
|
|
44
|
+
left_layout = QVBoxLayout(left_panel)
|
|
45
|
+
left_layout.setContentsMargins(8, 8, 8, 8)
|
|
46
|
+
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
|
|
47
|
+
left_layout.addStretch(1)
|
|
48
|
+
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
|
49
|
+
|
|
50
|
+
self.editor = QPlainTextEdit()
|
|
51
|
+
tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ")
|
|
52
|
+
self.editor.setTabStopDistance(tab_w)
|
|
53
|
+
self.highlighter = MarkdownHighlighter(self.editor.document())
|
|
54
|
+
|
|
55
|
+
split = QSplitter()
|
|
56
|
+
split.addWidget(left_panel)
|
|
57
|
+
split.addWidget(self.editor)
|
|
58
|
+
split.setStretchFactor(1, 1) # editor grows
|
|
59
|
+
|
|
60
|
+
container = QWidget()
|
|
61
|
+
lay = QVBoxLayout(container)
|
|
62
|
+
lay.addWidget(split)
|
|
63
|
+
self.setCentralWidget(container)
|
|
64
|
+
|
|
65
|
+
# Status bar for feedback
|
|
66
|
+
self.statusBar().showMessage("Ready", 800)
|
|
67
|
+
|
|
68
|
+
# Menu bar (File)
|
|
69
|
+
mb = self.menuBar()
|
|
70
|
+
file_menu = mb.addMenu("&File")
|
|
71
|
+
act_save = QAction("&Save", self)
|
|
72
|
+
act_save.setShortcut("Ctrl+S")
|
|
73
|
+
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
74
|
+
file_menu.addAction(act_save)
|
|
75
|
+
act_settings = QAction("&Settings", self)
|
|
76
|
+
act_settings.triggered.connect(self._open_settings)
|
|
77
|
+
file_menu.addAction(act_settings)
|
|
78
|
+
file_menu.addSeparator()
|
|
79
|
+
act_quit = QAction("&Quit", self)
|
|
80
|
+
act_quit.setShortcut("Ctrl+Q")
|
|
81
|
+
act_quit.triggered.connect(self.close)
|
|
82
|
+
file_menu.addAction(act_quit)
|
|
83
|
+
|
|
84
|
+
# Navigate menu with next/previous day
|
|
85
|
+
nav_menu = mb.addMenu("&Navigate")
|
|
86
|
+
act_prev = QAction("Previous Day", self)
|
|
87
|
+
act_prev.setShortcut("Ctrl+P")
|
|
88
|
+
act_prev.setShortcutContext(Qt.ApplicationShortcut)
|
|
89
|
+
act_prev.triggered.connect(lambda: self._adjust_day(-1))
|
|
90
|
+
nav_menu.addAction(act_prev)
|
|
91
|
+
self.addAction(act_prev)
|
|
92
|
+
|
|
93
|
+
act_next = QAction("Next Day", self)
|
|
94
|
+
act_next.setShortcut("Ctrl+N")
|
|
95
|
+
act_next.setShortcutContext(Qt.ApplicationShortcut)
|
|
96
|
+
act_next.triggered.connect(lambda: self._adjust_day(1))
|
|
97
|
+
nav_menu.addAction(act_next)
|
|
98
|
+
self.addAction(act_next)
|
|
99
|
+
|
|
100
|
+
# Autosave
|
|
101
|
+
self._dirty = False
|
|
102
|
+
self._save_timer = QTimer(self)
|
|
103
|
+
self._save_timer.setSingleShot(True)
|
|
104
|
+
self._save_timer.timeout.connect(self._save_current)
|
|
105
|
+
self.editor.textChanged.connect(self._on_text_changed)
|
|
106
|
+
|
|
107
|
+
# First load + mark dates with content
|
|
108
|
+
self._load_selected_date()
|
|
109
|
+
self._refresh_calendar_marks()
|
|
110
|
+
|
|
111
|
+
# --- DB lifecycle
|
|
112
|
+
def _try_connect(self) -> bool:
|
|
113
|
+
try:
|
|
114
|
+
self.db = DBManager(self.cfg)
|
|
115
|
+
ok = self.db.connect()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
if str(e) == "file is not a database":
|
|
118
|
+
error = "The key is probably incorrect."
|
|
119
|
+
else:
|
|
120
|
+
error = str(e)
|
|
121
|
+
QMessageBox.critical(self, "Database Error", error)
|
|
122
|
+
return False
|
|
123
|
+
return ok
|
|
124
|
+
|
|
125
|
+
def _prompt_for_key_until_valid(self) -> bool:
|
|
126
|
+
while True:
|
|
127
|
+
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
|
128
|
+
if dlg.exec() != QDialog.Accepted:
|
|
129
|
+
return False
|
|
130
|
+
self.cfg.key = dlg.key()
|
|
131
|
+
if self._try_connect():
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# --- Calendar marks to indicate text exists for htat day -----------------
|
|
135
|
+
def _refresh_calendar_marks(self):
|
|
136
|
+
fmt_bold = QTextCharFormat()
|
|
137
|
+
fmt_bold.setFontWeight(QFont.Weight.Bold)
|
|
138
|
+
# Clear previous marks
|
|
139
|
+
for d in getattr(self, "_marked_dates", set()):
|
|
140
|
+
self.calendar.setDateTextFormat(d, QTextCharFormat())
|
|
141
|
+
self._marked_dates = set()
|
|
142
|
+
try:
|
|
143
|
+
for date_iso in self.db.dates_with_content():
|
|
144
|
+
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
145
|
+
if qd.isValid():
|
|
146
|
+
self.calendar.setDateTextFormat(qd, fmt_bold)
|
|
147
|
+
self._marked_dates.add(qd)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# --- UI handlers ---------------------------------------------------------
|
|
152
|
+
def _current_date_iso(self) -> str:
|
|
153
|
+
d = self.calendar.selectedDate()
|
|
154
|
+
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
|
155
|
+
|
|
156
|
+
def _load_selected_date(self):
|
|
157
|
+
date_iso = self._current_date_iso()
|
|
158
|
+
try:
|
|
159
|
+
text = self.db.get_entry(date_iso)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
QMessageBox.critical(self, "Read Error", str(e))
|
|
162
|
+
return
|
|
163
|
+
self.editor.blockSignals(True)
|
|
164
|
+
self.editor.setPlainText(text)
|
|
165
|
+
self.editor.blockSignals(False)
|
|
166
|
+
self._dirty = False
|
|
167
|
+
# track which date the editor currently represents
|
|
168
|
+
self._active_date_iso = date_iso
|
|
169
|
+
|
|
170
|
+
def _on_text_changed(self):
|
|
171
|
+
self._dirty = True
|
|
172
|
+
self._save_timer.start(1200) # autosave after idle
|
|
173
|
+
|
|
174
|
+
def _adjust_day(self, delta: int):
|
|
175
|
+
"""Move selection by delta days (negative for previous)."""
|
|
176
|
+
d = self.calendar.selectedDate().addDays(delta)
|
|
177
|
+
self.calendar.setSelectedDate(d)
|
|
178
|
+
|
|
179
|
+
def _on_date_changed(self):
|
|
180
|
+
"""
|
|
181
|
+
When the calendar selection changes, save the previous day's note if dirty,
|
|
182
|
+
so we don't lose that text, then load the newly selected day.
|
|
183
|
+
"""
|
|
184
|
+
# Stop pending autosave and persist current buffer if needed
|
|
185
|
+
try:
|
|
186
|
+
self._save_timer.stop()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
prev = getattr(self, "_active_date_iso", None)
|
|
190
|
+
if prev and self._dirty:
|
|
191
|
+
self._save_date(prev, explicit=False)
|
|
192
|
+
# Now load the newly selected date
|
|
193
|
+
self._load_selected_date()
|
|
194
|
+
|
|
195
|
+
def _save_date(self, date_iso: str, explicit: bool = False):
|
|
196
|
+
"""
|
|
197
|
+
Save editor contents into the given date. Shows status on success.
|
|
198
|
+
explicit=True means user invoked Save: show feedback even if nothing changed.
|
|
199
|
+
"""
|
|
200
|
+
if not self._dirty and not explicit:
|
|
201
|
+
return
|
|
202
|
+
text = self.editor.toPlainText()
|
|
203
|
+
try:
|
|
204
|
+
self.db.upsert_entry(date_iso, text)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
QMessageBox.critical(self, "Save Error", str(e))
|
|
207
|
+
return
|
|
208
|
+
self._dirty = False
|
|
209
|
+
self._refresh_calendar_marks()
|
|
210
|
+
# Feedback in the status bar
|
|
211
|
+
from datetime import datetime as _dt
|
|
212
|
+
|
|
213
|
+
self.statusBar().showMessage(
|
|
214
|
+
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _save_current(self, explicit: bool = False):
|
|
218
|
+
# Delegate to _save_date for the currently selected date
|
|
219
|
+
self._save_date(self._current_date_iso(), explicit)
|
|
220
|
+
|
|
221
|
+
def _open_settings(self):
|
|
222
|
+
dlg = SettingsDialog(self.cfg, self)
|
|
223
|
+
if dlg.exec() == QDialog.Accepted:
|
|
224
|
+
new_cfg = dlg.config
|
|
225
|
+
if new_cfg.path != self.cfg.path:
|
|
226
|
+
# Save the new path to the notebook
|
|
227
|
+
self.cfg.path = new_cfg.path
|
|
228
|
+
save_db_config(self.cfg)
|
|
229
|
+
self.db.close()
|
|
230
|
+
# Prompt again for the key for the new path
|
|
231
|
+
if not self._prompt_for_key_until_valid():
|
|
232
|
+
QMessageBox.warning(
|
|
233
|
+
self, "Reopen failed", "Could not unlock database at new path."
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
self._load_selected_date()
|
|
237
|
+
self._refresh_calendar_marks()
|
|
238
|
+
|
|
239
|
+
def closeEvent(self, event): # noqa: N802
|
|
240
|
+
try:
|
|
241
|
+
self._save_current()
|
|
242
|
+
self.db.close()
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
super().closeEvent(event)
|
bouquin/settings.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from PySide6.QtCore import QSettings, QStandardPaths
|
|
5
|
+
|
|
6
|
+
from .db import DBConfig
|
|
7
|
+
|
|
8
|
+
APP_ORG = "Bouquin"
|
|
9
|
+
APP_NAME = "Bouquin"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_db_path() -> Path:
|
|
13
|
+
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
|
|
14
|
+
return base / "notebook.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_settings() -> QSettings:
|
|
18
|
+
return QSettings(APP_ORG, APP_NAME)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_db_config() -> DBConfig:
|
|
22
|
+
s = get_settings()
|
|
23
|
+
path = Path(s.value("db/path", str(default_db_path())))
|
|
24
|
+
return DBConfig(path=path, key="")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_db_config(cfg: DBConfig) -> None:
|
|
28
|
+
s = get_settings()
|
|
29
|
+
s.setValue("db/path", str(cfg.path))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QDialog,
|
|
7
|
+
QFormLayout,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QVBoxLayout,
|
|
10
|
+
QWidget,
|
|
11
|
+
QLineEdit,
|
|
12
|
+
QPushButton,
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QDialogButtonBox,
|
|
15
|
+
QSizePolicy,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .db import DBConfig
|
|
19
|
+
from .settings import save_db_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SettingsDialog(QDialog):
|
|
23
|
+
def __init__(self, cfg: DBConfig, parent=None):
|
|
24
|
+
super().__init__(parent)
|
|
25
|
+
self.setWindowTitle("Settings")
|
|
26
|
+
self._cfg = DBConfig(path=cfg.path, key="")
|
|
27
|
+
|
|
28
|
+
form = QFormLayout()
|
|
29
|
+
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
|
30
|
+
self.setMinimumWidth(520)
|
|
31
|
+
self.setSizeGripEnabled(True)
|
|
32
|
+
|
|
33
|
+
self.path_edit = QLineEdit(str(self._cfg.path))
|
|
34
|
+
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
35
|
+
browse_btn = QPushButton("Browse…")
|
|
36
|
+
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
37
|
+
browse_btn.clicked.connect(self._browse)
|
|
38
|
+
path_row = QWidget()
|
|
39
|
+
h = QHBoxLayout(path_row)
|
|
40
|
+
h.setContentsMargins(0, 0, 0, 0)
|
|
41
|
+
h.addWidget(self.path_edit, 1)
|
|
42
|
+
h.addWidget(browse_btn, 0)
|
|
43
|
+
h.setStretch(0, 1)
|
|
44
|
+
h.setStretch(1, 0)
|
|
45
|
+
form.addRow("Database path", path_row)
|
|
46
|
+
|
|
47
|
+
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
48
|
+
bb.accepted.connect(self._save)
|
|
49
|
+
bb.rejected.connect(self.reject)
|
|
50
|
+
|
|
51
|
+
v = QVBoxLayout(self)
|
|
52
|
+
v.addLayout(form)
|
|
53
|
+
v.addWidget(bb)
|
|
54
|
+
|
|
55
|
+
def _browse(self):
|
|
56
|
+
p, _ = QFileDialog.getSaveFileName(
|
|
57
|
+
self,
|
|
58
|
+
"Choose database file",
|
|
59
|
+
self.path_edit.text(),
|
|
60
|
+
"DB Files (*.db);;All Files (*)",
|
|
61
|
+
)
|
|
62
|
+
if p:
|
|
63
|
+
self.path_edit.setText(p)
|
|
64
|
+
|
|
65
|
+
def _save(self):
|
|
66
|
+
self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
|
|
67
|
+
save_db_config(self._cfg)
|
|
68
|
+
self.accept()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def config(self) -> DBConfig:
|
|
72
|
+
return self._cfg
|