bouquin 0.1.3__py3-none-any.whl → 0.1.5__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/db.py CHANGED
@@ -17,6 +17,7 @@ Entry = Tuple[str, str]
17
17
  class DBConfig:
18
18
  path: Path
19
19
  key: str
20
+ idle_minutes: int = 15 # 0 = never lock
20
21
 
21
22
 
22
23
  class DBManager:
@@ -25,14 +26,17 @@ class DBManager:
25
26
  self.conn: sqlite.Connection | None = None
26
27
 
27
28
  def connect(self) -> bool:
29
+ """
30
+ Open, decrypt and install schema on the database.
31
+ """
28
32
  # Ensure parent dir exists
29
33
  self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
30
34
  self.conn = sqlite.connect(str(self.cfg.path))
31
35
  self.conn.row_factory = sqlite.Row
32
36
  cur = self.conn.cursor()
33
37
  cur.execute(f"PRAGMA key = '{self.cfg.key}';")
34
- cur.execute("PRAGMA journal_mode = WAL;")
35
- self.conn.commit()
38
+ cur.execute("PRAGMA foreign_keys = ON;")
39
+ cur.execute("PRAGMA journal_mode = WAL;").fetchone()
36
40
  try:
37
41
  self._integrity_ok()
38
42
  except Exception:
@@ -43,15 +47,18 @@ class DBManager:
43
47
  return True
44
48
 
45
49
  def _integrity_ok(self) -> bool:
50
+ """
51
+ Runs the cipher_integrity_check PRAGMA on the database.
52
+ """
46
53
  cur = self.conn.cursor()
47
54
  cur.execute("PRAGMA cipher_integrity_check;")
48
55
  rows = cur.fetchall()
49
56
 
50
- # OK
57
+ # OK: nothing returned
51
58
  if not rows:
52
59
  return
53
60
 
54
- # Not OK
61
+ # Not OK: rows of problems returned
55
62
  details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
56
63
  raise sqlite.IntegrityError(
57
64
  "SQLCipher integrity check failed"
@@ -59,16 +66,62 @@ class DBManager:
59
66
  )
60
67
 
61
68
  def _ensure_schema(self) -> None:
69
+ """
70
+ Install the expected schema on the database.
71
+ We also handle upgrades here.
72
+ """
62
73
  cur = self.conn.cursor()
63
- cur.execute(
74
+ # Always keep FKs on
75
+ cur.execute("PRAGMA foreign_keys = ON;")
76
+
77
+ # Create new versioned schema if missing (< 0.1.5)
78
+ cur.executescript(
64
79
  """
65
- CREATE TABLE IF NOT EXISTS entries (
66
- date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
67
- content TEXT NOT NULL
80
+ CREATE TABLE IF NOT EXISTS pages (
81
+ date TEXT PRIMARY KEY, -- yyyy-MM-dd
82
+ current_version_id INTEGER,
83
+ FOREIGN KEY(current_version_id) REFERENCES versions(id) ON DELETE SET NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS versions (
87
+ id INTEGER PRIMARY KEY,
88
+ date TEXT NOT NULL, -- FK to pages.date
89
+ version_no INTEGER NOT NULL, -- 1,2,3… per date
90
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
91
+ note TEXT,
92
+ content TEXT NOT NULL,
93
+ FOREIGN KEY(date) REFERENCES pages(date) ON DELETE CASCADE
68
94
  );
95
+
96
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
97
+ CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
69
98
  """
70
99
  )
71
- cur.execute("PRAGMA user_version = 1;")
100
+
101
+ # If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
102
+ pre_0_1_5 = cur.execute(
103
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
104
+ ).fetchone()
105
+ pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
106
+
107
+ if pre_0_1_5 and pages_empty:
108
+ # Seed pages and versions (all as version 1)
109
+ cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
110
+ cur.execute(
111
+ "INSERT INTO versions(date, version_no, content) "
112
+ "SELECT date, 1, content FROM entries;"
113
+ )
114
+ # Point head to v1 for each page
115
+ cur.execute(
116
+ """
117
+ UPDATE pages
118
+ SET current_version_id = (
119
+ SELECT v.id FROM versions v
120
+ WHERE v.date = pages.date AND v.version_no = 1
121
+ );
122
+ """
123
+ )
124
+ cur.execute("DROP TABLE IF EXISTS entries;")
72
125
  self.conn.commit()
73
126
 
74
127
  def rekey(self, new_key: str) -> None:
@@ -91,42 +144,214 @@ class DBManager:
91
144
  raise sqlite.Error("Re-open failed after rekey")
92
145
 
93
146
  def get_entry(self, date_iso: str) -> str:
147
+ """
148
+ Get a single entry by its date.
149
+ """
94
150
  cur = self.conn.cursor()
95
- cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
96
- row = cur.fetchone()
151
+ row = cur.execute(
152
+ """
153
+ SELECT v.content
154
+ FROM pages p
155
+ JOIN versions v ON v.id = p.current_version_id
156
+ WHERE p.date = ?;
157
+ """,
158
+ (date_iso,),
159
+ ).fetchone()
97
160
  return row[0] if row else ""
98
161
 
99
162
  def upsert_entry(self, date_iso: str, content: str) -> None:
100
- cur = self.conn.cursor()
101
- cur.execute(
102
- """
103
- INSERT INTO entries(date, content) VALUES(?, ?)
104
- ON CONFLICT(date) DO UPDATE SET content = excluded.content;
105
- """,
106
- (date_iso, content),
107
- )
108
- self.conn.commit()
163
+ """
164
+ Insert or update an entry.
165
+ """
166
+ # Make a new version and set it as current
167
+ self.save_new_version(date_iso, content, note=None, set_current=True)
109
168
 
110
169
  def search_entries(self, text: str) -> list[str]:
170
+ """
171
+ Search for entries by term. This only works against the latest
172
+ version of the page.
173
+ """
111
174
  cur = self.conn.cursor()
112
175
  pattern = f"%{text}%"
113
- return cur.execute(
114
- "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
176
+ rows = cur.execute(
177
+ """
178
+ SELECT p.date, v.content
179
+ FROM pages AS p
180
+ JOIN versions AS v
181
+ ON v.id = p.current_version_id
182
+ WHERE TRIM(v.content) <> ''
183
+ AND v.content LIKE LOWER(?) ESCAPE '\\'
184
+ ORDER BY p.date DESC;
185
+ """,
186
+ (pattern,),
115
187
  ).fetchall()
188
+ return [(r[0], r[1]) for r in rows]
116
189
 
117
190
  def dates_with_content(self) -> list[str]:
191
+ """
192
+ Find all entries and return the dates of them.
193
+ This is used to mark the calendar days in bold if they contain entries.
194
+ """
195
+ cur = self.conn.cursor()
196
+ rows = cur.execute(
197
+ """
198
+ SELECT p.date
199
+ FROM pages p
200
+ JOIN versions v ON v.id = p.current_version_id
201
+ WHERE TRIM(v.content) <> ''
202
+ ORDER BY p.date;
203
+ """
204
+ ).fetchall()
205
+ return [r[0] for r in rows]
206
+
207
+ # ------------------------- Versioning logic here ------------------------#
208
+ def save_new_version(
209
+ self,
210
+ date_iso: str,
211
+ content: str,
212
+ note: str | None = None,
213
+ set_current: bool = True,
214
+ ) -> tuple[int, int]:
215
+ """
216
+ Append a new version for this date. Returns (version_id, version_no).
217
+ If set_current=True, flips the page head to this new version.
218
+ """
219
+ if self.conn is None:
220
+ raise RuntimeError("Database is not connected")
221
+ with self.conn: # transaction
222
+ cur = self.conn.cursor()
223
+ # Ensure page row exists
224
+ cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
225
+ # Next version number
226
+ row = cur.execute(
227
+ "SELECT COALESCE(MAX(version_no), 0) AS maxv FROM versions WHERE date=?;",
228
+ (date_iso,),
229
+ ).fetchone()
230
+ next_ver = int(row["maxv"]) + 1
231
+ # Insert the version
232
+ cur.execute(
233
+ "INSERT INTO versions(date, version_no, content, note) "
234
+ "VALUES (?,?,?,?);",
235
+ (date_iso, next_ver, content, note),
236
+ )
237
+ ver_id = cur.lastrowid
238
+ if set_current:
239
+ cur.execute(
240
+ "UPDATE pages SET current_version_id=? WHERE date=?;",
241
+ (ver_id, date_iso),
242
+ )
243
+ return ver_id, next_ver
244
+
245
+ def list_versions(self, date_iso: str) -> list[dict]:
246
+ """
247
+ Returns history for a given date (newest first), including which one is current.
248
+ Each item: {id, version_no, created_at, note, is_current}
249
+ """
118
250
  cur = self.conn.cursor()
119
- cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
120
- return [r[0] for r in cur.fetchall()]
251
+ rows = cur.execute(
252
+ """
253
+ SELECT v.id, v.version_no, v.created_at, v.note,
254
+ CASE WHEN v.id = p.current_version_id THEN 1 ELSE 0 END AS is_current
255
+ FROM versions v
256
+ LEFT JOIN pages p ON p.date = v.date
257
+ WHERE v.date = ?
258
+ ORDER BY v.version_no DESC;
259
+ """,
260
+ (date_iso,),
261
+ ).fetchall()
262
+ return [dict(r) for r in rows]
121
263
 
264
+ def get_version(
265
+ self,
266
+ *,
267
+ date_iso: str | None = None,
268
+ version_no: int | None = None,
269
+ version_id: int | None = None,
270
+ ) -> dict | None:
271
+ """
272
+ Fetch a specific version by (date, version_no) OR by version_id.
273
+ Returns a dict with keys: id, date, version_no, created_at, note, content.
274
+ """
275
+ cur = self.conn.cursor()
276
+ if version_id is not None:
277
+ row = cur.execute(
278
+ "SELECT id, date, version_no, created_at, note, content "
279
+ "FROM versions WHERE id=?;",
280
+ (version_id,),
281
+ ).fetchone()
282
+ else:
283
+ if date_iso is None or version_no is None:
284
+ raise ValueError(
285
+ "Provide either version_id OR (date_iso and version_no)"
286
+ )
287
+ row = cur.execute(
288
+ "SELECT id, date, version_no, created_at, note, content "
289
+ "FROM versions WHERE date=? AND version_no=?;",
290
+ (date_iso, version_no),
291
+ ).fetchone()
292
+ return dict(row) if row else None
293
+
294
+ def revert_to_version(
295
+ self,
296
+ date_iso: str,
297
+ *,
298
+ version_no: int | None = None,
299
+ version_id: int | None = None,
300
+ ) -> None:
301
+ """
302
+ Point the page head (pages.current_version_id) to an existing version.
303
+ Fast revert: no content is rewritten.
304
+ """
305
+ if self.conn is None:
306
+ raise RuntimeError("Database is not connected")
307
+ cur = self.conn.cursor()
308
+
309
+ if version_id is None:
310
+ if version_no is None:
311
+ raise ValueError("Provide version_no or version_id")
312
+ row = cur.execute(
313
+ "SELECT id FROM versions WHERE date=? AND version_no=?;",
314
+ (date_iso, version_no),
315
+ ).fetchone()
316
+ if row is None:
317
+ raise ValueError("Version not found for this date")
318
+ version_id = int(row["id"])
319
+ else:
320
+ # Ensure that version_id belongs to the given date
321
+ row = cur.execute(
322
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
323
+ ).fetchone()
324
+ if row is None or row["date"] != date_iso:
325
+ raise ValueError("version_id does not belong to the given date")
326
+
327
+ with self.conn:
328
+ cur.execute(
329
+ "UPDATE pages SET current_version_id=? WHERE date=?;",
330
+ (version_id, date_iso),
331
+ )
332
+
333
+ # ------------------------- Export logic here ------------------------#
122
334
  def get_all_entries(self) -> List[Entry]:
335
+ """
336
+ Get all entries. Used for exports.
337
+ """
123
338
  cur = self.conn.cursor()
124
- rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
125
- return [(row["date"], row["content"]) for row in rows]
339
+ rows = cur.execute(
340
+ """
341
+ SELECT p.date, v.content
342
+ FROM pages p
343
+ JOIN versions v ON v.id = p.current_version_id
344
+ ORDER BY p.date;
345
+ """
346
+ ).fetchall()
347
+ return [(r[0], r[1]) for r in rows]
126
348
 
127
349
  def export_json(
128
350
  self, entries: Sequence[Entry], file_path: str, pretty: bool = True
129
351
  ) -> None:
352
+ """
353
+ Export to json.
354
+ """
130
355
  data = [{"date": d, "content": c} for d, c in entries]
131
356
  with open(file_path, "w", encoding="utf-8") as f:
132
357
  if pretty:
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib, re, html as _html
4
+ from PySide6.QtCore import Qt, Slot
5
+ from PySide6.QtWidgets import (
6
+ QDialog,
7
+ QVBoxLayout,
8
+ QHBoxLayout,
9
+ QListWidget,
10
+ QListWidgetItem,
11
+ QPushButton,
12
+ QMessageBox,
13
+ QTextBrowser,
14
+ QTabWidget,
15
+ )
16
+
17
+
18
+ def _html_to_text(s: str) -> str:
19
+ """Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
20
+ STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
21
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
22
+ BR_RE = re.compile(r"(?i)<br\s*/?>")
23
+ BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
24
+ TAG_RE = re.compile(r"<[^>]+>")
25
+ MULTINL_RE = re.compile(r"\n{3,}")
26
+
27
+ s = STYLE_SCRIPT_RE.sub("", s)
28
+ s = COMMENT_RE.sub("", s)
29
+ s = BR_RE.sub("\n", s)
30
+ s = BLOCK_END_RE.sub("\n", s)
31
+ s = TAG_RE.sub("", s)
32
+ s = _html.unescape(s)
33
+ s = MULTINL_RE.sub("\n\n", s)
34
+ return s.strip()
35
+
36
+
37
+ def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
38
+ """Return HTML with colored unified diff (+ green, - red, context gray)."""
39
+ a = _html_to_text(old_html).splitlines()
40
+ b = _html_to_text(new_html).splitlines()
41
+ ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
42
+ lines = []
43
+ for line in ud:
44
+ if line.startswith("+") and not line.startswith("+++"):
45
+ lines.append(
46
+ f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
47
+ )
48
+ elif line.startswith("-") and not line.startswith("---"):
49
+ lines.append(
50
+ f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
51
+ )
52
+ elif line.startswith("@@"):
53
+ lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
54
+ else:
55
+ lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
56
+ css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
57
+ return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
58
+
59
+
60
+ class HistoryDialog(QDialog):
61
+ """Show versions for a date, preview, diff, and allow revert."""
62
+
63
+ def __init__(self, db, date_iso: str, parent=None):
64
+ super().__init__(parent)
65
+ self.setWindowTitle(f"History — {date_iso}")
66
+ self._db = db
67
+ self._date = date_iso
68
+ self._versions = [] # list[dict] from DB
69
+ self._current_id = None # id of current
70
+
71
+ root = QVBoxLayout(self)
72
+
73
+ # Top: list of versions
74
+ top = QHBoxLayout()
75
+ self.list = QListWidget()
76
+ self.list.setMinimumSize(500, 650)
77
+ self.list.currentItemChanged.connect(self._on_select)
78
+ top.addWidget(self.list, 1)
79
+
80
+ # Right: tabs (Preview / Diff vs current)
81
+ self.tabs = QTabWidget()
82
+ self.preview = QTextBrowser()
83
+ self.preview.setOpenExternalLinks(True)
84
+ self.diff = QTextBrowser()
85
+ self.diff.setOpenExternalLinks(False)
86
+ self.tabs.addTab(self.preview, "Preview")
87
+ self.tabs.addTab(self.diff, "Diff vs current")
88
+ self.tabs.setMinimumSize(500, 650)
89
+ top.addWidget(self.tabs, 2)
90
+
91
+ root.addLayout(top)
92
+
93
+ # Buttons
94
+ row = QHBoxLayout()
95
+ row.addStretch(1)
96
+ self.btn_revert = QPushButton("Revert to Selected")
97
+ self.btn_revert.clicked.connect(self._revert)
98
+ self.btn_close = QPushButton("Close")
99
+ self.btn_close.clicked.connect(self.reject)
100
+ row.addWidget(self.btn_revert)
101
+ row.addWidget(self.btn_close)
102
+ root.addLayout(row)
103
+
104
+ self._load_versions()
105
+
106
+ # --- Data/UX helpers ---
107
+ def _load_versions(self):
108
+ self._versions = self._db.list_versions(
109
+ self._date
110
+ ) # [{id,version_no,created_at,note,is_current}]
111
+ self._current_id = next(
112
+ (v["id"] for v in self._versions if v["is_current"]), None
113
+ )
114
+ self.list.clear()
115
+ for v in self._versions:
116
+ label = f"v{v['version_no']} — {v['created_at']}"
117
+ if v.get("note"):
118
+ label += f" · {v['note']}"
119
+ if v["is_current"]:
120
+ label += " **(current)**"
121
+ it = QListWidgetItem(label)
122
+ it.setData(Qt.UserRole, v["id"])
123
+ self.list.addItem(it)
124
+ # select the first non-current if available, else current
125
+ idx = 0
126
+ for i, v in enumerate(self._versions):
127
+ if not v["is_current"]:
128
+ idx = i
129
+ break
130
+ if self.list.count():
131
+ self.list.setCurrentRow(idx)
132
+
133
+ @Slot()
134
+ def _on_select(self):
135
+ item = self.list.currentItem()
136
+ if not item:
137
+ self.preview.clear()
138
+ self.diff.clear()
139
+ self.btn_revert.setEnabled(False)
140
+ return
141
+ sel_id = item.data(Qt.UserRole)
142
+ # Preview selected as HTML
143
+ sel = self._db.get_version(version_id=sel_id)
144
+ self.preview.setHtml(sel["content"])
145
+ # Diff vs current (textual diff)
146
+ cur = self._db.get_version(version_id=self._current_id)
147
+ self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
148
+ # Enable revert only if selecting a non-current
149
+ self.btn_revert.setEnabled(sel_id != self._current_id)
150
+
151
+ @Slot()
152
+ def _revert(self):
153
+ item = self.list.currentItem()
154
+ if not item:
155
+ return
156
+ sel_id = item.data(Qt.UserRole)
157
+ if sel_id == self._current_id:
158
+ return
159
+ sel = self._db.get_version(version_id=sel_id)
160
+ vno = sel["version_no"]
161
+ # Confirm
162
+ if (
163
+ QMessageBox.question(
164
+ self,
165
+ "Revert",
166
+ f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
167
+ QMessageBox.Yes | QMessageBox.No,
168
+ )
169
+ != QMessageBox.Yes
170
+ ):
171
+ return
172
+ # Flip head pointer
173
+ try:
174
+ self._db.revert_to_version(self._date, version_id=sel_id)
175
+ except Exception as e:
176
+ QMessageBox.critical(self, "Revert failed", str(e))
177
+ return
178
+ QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
179
+ self.accept() # let the caller refresh the editor
bouquin/main_window.py CHANGED
@@ -4,7 +4,7 @@ import os
4
4
  import sys
5
5
 
6
6
  from pathlib import Path
7
- from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
7
+ from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
8
8
  from PySide6.QtGui import (
9
9
  QAction,
10
10
  QCursor,
@@ -17,8 +17,10 @@ from PySide6.QtWidgets import (
17
17
  QCalendarWidget,
18
18
  QDialog,
19
19
  QFileDialog,
20
+ QLabel,
20
21
  QMainWindow,
21
22
  QMessageBox,
23
+ QPushButton,
22
24
  QSizePolicy,
23
25
  QSplitter,
24
26
  QVBoxLayout,
@@ -27,13 +29,70 @@ from PySide6.QtWidgets import (
27
29
 
28
30
  from .db import DBManager
29
31
  from .editor import Editor
32
+ from .history_dialog import HistoryDialog
30
33
  from .key_prompt import KeyPrompt
34
+ from .save_dialog import SaveDialog
31
35
  from .search import Search
32
36
  from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
33
37
  from .settings_dialog import SettingsDialog
34
38
  from .toolbar import ToolBar
35
39
 
36
40
 
41
+ class _LockOverlay(QWidget):
42
+ def __init__(self, parent: QWidget, on_unlock: callable):
43
+ super().__init__(parent)
44
+ self.setObjectName("LockOverlay")
45
+ self.setAttribute(Qt.WA_StyledBackground, True)
46
+ self.setFocusPolicy(Qt.StrongFocus)
47
+ self.setGeometry(parent.rect())
48
+
49
+ self.setStyleSheet(
50
+ """
51
+ #LockOverlay { background-color: #ccc; }
52
+ #LockOverlay QLabel { color: #fff; font-size: 18px; }
53
+ #LockOverlay QPushButton {
54
+ background-color: #f2f2f2;
55
+ color: #000;
56
+ padding: 6px 14px;
57
+ border: 1px solid #808080;
58
+ border-radius: 6px;
59
+ font-size: 14px;
60
+ }
61
+ #LockOverlay QPushButton:hover { background-color: #ffffff; }
62
+ #LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
63
+ """
64
+ )
65
+
66
+ lay = QVBoxLayout(self)
67
+ lay.addStretch(1)
68
+
69
+ msg = QLabel("Locked due to inactivity")
70
+ msg.setAlignment(Qt.AlignCenter)
71
+
72
+ self._btn = QPushButton("Unlock")
73
+ self._btn.setFixedWidth(200)
74
+ self._btn.setCursor(Qt.PointingHandCursor)
75
+ self._btn.setAutoDefault(True)
76
+ self._btn.setDefault(True)
77
+ self._btn.clicked.connect(on_unlock)
78
+
79
+ lay.addWidget(msg, 0, Qt.AlignCenter)
80
+ lay.addWidget(self._btn, 0, Qt.AlignCenter)
81
+ lay.addStretch(1)
82
+
83
+ self.hide() # start hidden
84
+
85
+ # keep overlay sized with its parent
86
+ def eventFilter(self, obj, event):
87
+ if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
88
+ self.setGeometry(obj.rect())
89
+ return False
90
+
91
+ def showEvent(self, e):
92
+ super().showEvent(e)
93
+ self._btn.setFocus()
94
+
95
+
37
96
  class MainWindow(QMainWindow):
38
97
  def __init__(self):
39
98
  super().__init__()
@@ -77,18 +136,19 @@ class MainWindow(QMainWindow):
77
136
  self.editor = Editor()
78
137
 
79
138
  # Toolbar for controlling styling
80
- tb = ToolBar()
81
- self.addToolBar(tb)
139
+ self.toolBar = ToolBar()
140
+ self.addToolBar(self.toolBar)
82
141
  # Wire toolbar intents to editor methods
83
- tb.boldRequested.connect(self.editor.apply_weight)
84
- tb.italicRequested.connect(self.editor.apply_italic)
85
- tb.underlineRequested.connect(self.editor.apply_underline)
86
- tb.strikeRequested.connect(self.editor.apply_strikethrough)
87
- tb.codeRequested.connect(self.editor.apply_code)
88
- tb.headingRequested.connect(self.editor.apply_heading)
89
- tb.bulletsRequested.connect(self.editor.toggle_bullets)
90
- tb.numbersRequested.connect(self.editor.toggle_numbers)
91
- tb.alignRequested.connect(self.editor.setAlignment)
142
+ self.toolBar.boldRequested.connect(self.editor.apply_weight)
143
+ self.toolBar.italicRequested.connect(self.editor.apply_italic)
144
+ self.toolBar.underlineRequested.connect(self.editor.apply_underline)
145
+ self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
146
+ self.toolBar.codeRequested.connect(self.editor.apply_code)
147
+ self.toolBar.headingRequested.connect(self.editor.apply_heading)
148
+ self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
149
+ self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
150
+ self.toolBar.alignRequested.connect(self.editor.setAlignment)
151
+ self.toolBar.historyRequested.connect(self._open_history)
92
152
 
93
153
  split = QSplitter()
94
154
  split.addWidget(left_panel)
@@ -100,16 +160,39 @@ class MainWindow(QMainWindow):
100
160
  lay.addWidget(split)
101
161
  self.setCentralWidget(container)
102
162
 
163
+ # Idle lock setup
164
+ self._idle_timer = QTimer(self)
165
+ self._idle_timer.setSingleShot(True)
166
+ self._idle_timer.timeout.connect(self._enter_lock)
167
+ self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
168
+ self._idle_timer.start()
169
+
170
+ # full-window overlay that sits on top of the central widget
171
+ self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
172
+ self.centralWidget().installEventFilter(self._lock_overlay)
173
+
174
+ self._locked = False
175
+
176
+ # reset idle timer on any key press anywhere in the app
177
+ from PySide6.QtWidgets import QApplication
178
+
179
+ QApplication.instance().installEventFilter(self)
180
+
103
181
  # Status bar for feedback
104
182
  self.statusBar().showMessage("Ready", 800)
105
183
 
106
184
  # Menu bar (File)
107
185
  mb = self.menuBar()
108
186
  file_menu = mb.addMenu("&File")
109
- act_save = QAction("&Save", self)
187
+ act_save = QAction("&Save a version", self)
110
188
  act_save.setShortcut("Ctrl+S")
111
189
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
112
190
  file_menu.addAction(act_save)
191
+ act_history = QAction("History", self)
192
+ act_history.setShortcut("Ctrl+H")
193
+ act_history.setShortcutContext(Qt.ApplicationShortcut)
194
+ act_history.triggered.connect(self._open_history)
195
+ file_menu.addAction(act_history)
113
196
  act_settings = QAction("Settin&gs", self)
114
197
  act_settings.setShortcut("Ctrl+G")
115
198
  act_settings.triggered.connect(self._open_settings)
@@ -155,6 +238,12 @@ class MainWindow(QMainWindow):
155
238
  act_docs.triggered.connect(self._open_docs)
156
239
  help_menu.addAction(act_docs)
157
240
  self.addAction(act_docs)
241
+ act_bugs = QAction("Report a bug", self)
242
+ act_bugs.setShortcut("Ctrl+R")
243
+ act_bugs.setShortcutContext(Qt.ApplicationShortcut)
244
+ act_bugs.triggered.connect(self._open_bugs)
245
+ help_menu.addAction(act_bugs)
246
+ self.addAction(act_bugs)
158
247
 
159
248
  # Autosave
160
249
  self._dirty = False
@@ -249,7 +338,7 @@ class MainWindow(QMainWindow):
249
338
 
250
339
  def _on_text_changed(self):
251
340
  self._dirty = True
252
- self._save_timer.start(1200) # autosave after idle
341
+ self._save_timer.start(10000) # autosave after idle
253
342
 
254
343
  def _adjust_day(self, delta: int):
255
344
  """Move selection by delta days (negative for previous)."""
@@ -277,7 +366,7 @@ class MainWindow(QMainWindow):
277
366
  # Now load the newly selected date
278
367
  self._load_selected_date()
279
368
 
280
- def _save_date(self, date_iso: str, explicit: bool = False):
369
+ def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
281
370
  """
282
371
  Save editor contents into the given date. Shows status on success.
283
372
  explicit=True means user invoked Save: show feedback even if nothing changed.
@@ -286,7 +375,7 @@ class MainWindow(QMainWindow):
286
375
  return
287
376
  text = self.editor.toHtml()
288
377
  try:
289
- self.db.upsert_entry(date_iso, text)
378
+ self.db.save_new_version(date_iso, text, note)
290
379
  except Exception as e:
291
380
  QMessageBox.critical(self, "Save Error", str(e))
292
381
  return
@@ -300,27 +389,65 @@ class MainWindow(QMainWindow):
300
389
  )
301
390
 
302
391
  def _save_current(self, explicit: bool = False):
392
+ try:
393
+ self._save_timer.stop()
394
+ except Exception:
395
+ pass
396
+ if explicit:
397
+ # Prompt for a note
398
+ dlg = SaveDialog(self)
399
+ if dlg.exec() != QDialog.Accepted:
400
+ return
401
+ note = dlg.note_text()
402
+ else:
403
+ note = "autosave"
303
404
  # Delegate to _save_date for the currently selected date
304
- self._save_date(self._current_date_iso(), explicit)
405
+ self._save_date(self._current_date_iso(), explicit, note)
406
+ try:
407
+ self._save_timer.start()
408
+ except Exception:
409
+ pass
410
+
411
+ def _open_history(self):
412
+ date_iso = self._current_date_iso()
413
+ dlg = HistoryDialog(self.db, date_iso, self)
414
+ if dlg.exec() == QDialog.Accepted:
415
+ # refresh editor + calendar (head pointer may have changed)
416
+ self._load_selected_date(date_iso)
417
+ self._refresh_calendar_marks()
305
418
 
419
+ # ----------- Settings handler ------------#
306
420
  def _open_settings(self):
307
421
  dlg = SettingsDialog(self.cfg, self.db, self)
308
- if dlg.exec() == QDialog.Accepted:
309
- new_cfg = dlg.config
310
- if new_cfg.path != self.cfg.path:
311
- # Save the new path to the notebook
312
- self.cfg.path = new_cfg.path
313
- save_db_config(self.cfg)
314
- self.db.close()
315
- # Prompt again for the key for the new path
316
- if not self._prompt_for_key_until_valid():
317
- QMessageBox.warning(
318
- self, "Reopen failed", "Could not unlock database at new path."
319
- )
320
- return
321
- self._load_selected_date()
322
- self._refresh_calendar_marks()
422
+ if dlg.exec() != QDialog.Accepted:
423
+ return
323
424
 
425
+ new_cfg = dlg.config
426
+ old_path = self.cfg.path
427
+
428
+ # Update in-memory config from the dialog
429
+ self.cfg.path = new_cfg.path
430
+ self.cfg.key = new_cfg.key
431
+ self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
432
+
433
+ # Persist once
434
+ save_db_config(self.cfg)
435
+
436
+ # Apply idle setting immediately (restart the timer with new interval if it changed)
437
+ self._apply_idle_minutes(self.cfg.idle_minutes)
438
+
439
+ # If the DB path changed, reconnect
440
+ if self.cfg.path != old_path:
441
+ self.db.close()
442
+ if not self._prompt_for_key_until_valid(first_time=False):
443
+ QMessageBox.warning(
444
+ self, "Reopen failed", "Could not unlock database at new path."
445
+ )
446
+ return
447
+ self._load_selected_date()
448
+ self._refresh_calendar_marks()
449
+
450
+ # ------------ Window positioning --------------- #
324
451
  def _restore_window_position(self):
325
452
  geom = self.settings.value("main/geometry", None)
326
453
  state = self.settings.value("main/windowState", None)
@@ -354,6 +481,7 @@ class MainWindow(QMainWindow):
354
481
  # Center the window in that screen’s available area
355
482
  self.move(r.center() - self.rect().center())
356
483
 
484
+ # ----------------- Export handler ----------------- #
357
485
  @Slot()
358
486
  def _export(self):
359
487
  try:
@@ -402,9 +530,77 @@ class MainWindow(QMainWindow):
402
530
  url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
403
531
  url = QUrl.fromUserInput(url_str)
404
532
  if not QDesktopServices.openUrl(url):
405
- QMessageBox.warning(self, "Open Documentation",
406
- f"Couldn't open:\n{url.toDisplayString()}")
533
+ QMessageBox.warning(
534
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
535
+ )
407
536
 
537
+ def _open_bugs(self):
538
+ url_str = "https://nr.mig5.net/forms/mig5/contact"
539
+ url = QUrl.fromUserInput(url_str)
540
+ if not QDesktopServices.openUrl(url):
541
+ QMessageBox.warning(
542
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
543
+ )
544
+
545
+ # Idle handlers
546
+ def _apply_idle_minutes(self, minutes: int):
547
+ minutes = max(0, int(minutes))
548
+ if not hasattr(self, "_idle_timer"):
549
+ return
550
+ if minutes == 0:
551
+ self._idle_timer.stop()
552
+ # If you’re currently locked, unlock when user disables the timer:
553
+ if getattr(self, "_locked", False):
554
+ try:
555
+ self._locked = False
556
+ if hasattr(self, "_lock_overlay"):
557
+ self._lock_overlay.hide()
558
+ except Exception:
559
+ pass
560
+ else:
561
+ self._idle_timer.setInterval(minutes * 60 * 1000)
562
+ if not getattr(self, "_locked", False):
563
+ self._idle_timer.start()
564
+
565
+ def eventFilter(self, obj, event):
566
+ if event.type() == QEvent.KeyPress and not self._locked:
567
+ self._idle_timer.start()
568
+ return super().eventFilter(obj, event)
569
+
570
+ def _enter_lock(self):
571
+ if self._locked:
572
+ return
573
+ self._locked = True
574
+ if self.menuBar():
575
+ self.menuBar().setEnabled(False)
576
+ if self.statusBar():
577
+ self.statusBar().setEnabled(False)
578
+ tb = getattr(self, "toolBar", None)
579
+ if tb:
580
+ tb.setEnabled(False)
581
+ self._lock_overlay.show()
582
+ self._lock_overlay.raise_()
583
+
584
+ @Slot()
585
+ def _on_unlock_clicked(self):
586
+ try:
587
+ ok = self._prompt_for_key_until_valid(first_time=False)
588
+ except Exception as e:
589
+ QMessageBox.critical(self, "Unlock failed", str(e))
590
+ return
591
+ if ok:
592
+ self._locked = False
593
+ self._lock_overlay.hide()
594
+ if self.menuBar():
595
+ self.menuBar().setEnabled(True)
596
+ if self.statusBar():
597
+ self.statusBar().setEnabled(True)
598
+ tb = getattr(self, "toolBar", None)
599
+ if tb:
600
+ tb.setEnabled(True)
601
+ self._idle_timer.start()
602
+
603
+ # Close app handler - save window position and database
408
604
  def closeEvent(self, event):
409
605
  try:
410
606
  # Save window position
bouquin/save_dialog.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ from PySide6.QtWidgets import (
6
+ QDialog,
7
+ QVBoxLayout,
8
+ QLabel,
9
+ QLineEdit,
10
+ QDialogButtonBox,
11
+ )
12
+
13
+
14
+ class SaveDialog(QDialog):
15
+ def __init__(
16
+ self,
17
+ parent=None,
18
+ title: str = "Enter a name for this version",
19
+ message: str = "Enter a name for this version?",
20
+ ):
21
+ super().__init__(parent)
22
+ self.setWindowTitle(title)
23
+ v = QVBoxLayout(self)
24
+ v.addWidget(QLabel(message))
25
+ self.note = QLineEdit()
26
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
27
+ self.note.setText(f"New version I saved at {now}")
28
+ v.addWidget(self.note)
29
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
30
+ bb.accepted.connect(self.accept)
31
+ bb.rejected.connect(self.reject)
32
+ v.addWidget(bb)
33
+
34
+ def note_text(self) -> str:
35
+ return self.note.text()
bouquin/settings.py CHANGED
@@ -22,10 +22,12 @@ def load_db_config() -> DBConfig:
22
22
  s = get_settings()
23
23
  path = Path(s.value("db/path", str(default_db_path())))
24
24
  key = s.value("db/key", "")
25
- return DBConfig(path=path, key=key)
25
+ idle = s.value("db/idle_minutes", 15, type=int)
26
+ return DBConfig(path=path, key=key, idle_minutes=idle)
26
27
 
27
28
 
28
29
  def save_db_config(cfg: DBConfig) -> None:
29
30
  s = get_settings()
30
31
  s.setValue("db/path", str(cfg.path))
31
32
  s.setValue("db/key", str(cfg.key))
33
+ s.setValue("db/idle_minutes", str(cfg.idle_minutes))
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
17
17
  QFileDialog,
18
18
  QDialogButtonBox,
19
19
  QSizePolicy,
20
+ QSpinBox,
20
21
  QMessageBox,
21
22
  )
22
23
  from PySide6.QtCore import Qt, Slot
@@ -56,7 +57,7 @@ class SettingsDialog(QDialog):
56
57
  form.addRow("Database path", path_row)
57
58
 
58
59
  # Encryption settings
59
- enc_group = QGroupBox("Encryption")
60
+ enc_group = QGroupBox("Encryption and Privacy")
60
61
  enc = QVBoxLayout(enc_group)
61
62
  enc.setContentsMargins(12, 8, 12, 12)
62
63
  enc.setSpacing(6)
@@ -64,10 +65,8 @@ class SettingsDialog(QDialog):
64
65
  # Checkbox to remember key
65
66
  self.save_key_btn = QCheckBox("Remember key")
66
67
  current_settings = load_db_config()
67
- if current_settings.key:
68
- self.save_key_btn.setChecked(True)
69
- else:
70
- self.save_key_btn.setChecked(False)
68
+ self.key = current_settings.key or ""
69
+ self.save_key_btn.setChecked(bool(self.key))
71
70
  self.save_key_btn.setCursor(Qt.PointingHandCursor)
72
71
  self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
73
72
  enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
@@ -100,6 +99,31 @@ class SettingsDialog(QDialog):
100
99
  self.rekey_btn.clicked.connect(self._change_key)
101
100
  enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
102
101
 
102
+ self.idle_spin = QSpinBox()
103
+ self.idle_spin.setRange(0, 240)
104
+ self.idle_spin.setSingleStep(1)
105
+ self.idle_spin.setAccelerated(True)
106
+ self.idle_spin.setSuffix(" min")
107
+ self.idle_spin.setSpecialValueText("Never")
108
+ self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
109
+ enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
110
+ # Explanation for idle option (autolock)
111
+ self.idle_spin_label = QLabel(
112
+ "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
113
+ "Set to 0 (never) to never lock."
114
+ )
115
+ self.idle_spin_label.setWordWrap(True)
116
+ self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
117
+ # make it look secondary
118
+ spal = self.idle_spin_label.palette()
119
+ spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
120
+ self.idle_spin_label.setPalette(spal)
121
+
122
+ spin_row = QHBoxLayout()
123
+ spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
124
+ spin_row.addWidget(self.idle_spin_label)
125
+ enc.addLayout(spin_row)
126
+
103
127
  # Put the group into the form so it spans the full width nicely
104
128
  form.addRow(enc_group)
105
129
 
@@ -126,7 +150,12 @@ class SettingsDialog(QDialog):
126
150
  self.path_edit.setText(p)
127
151
 
128
152
  def _save(self):
129
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
153
+ key_to_save = self.key if self.save_key_btn.isChecked() else ""
154
+ self._cfg = DBConfig(
155
+ path=Path(self.path_edit.text()),
156
+ key=key_to_save,
157
+ idle_minutes=self.idle_spin.value(),
158
+ )
130
159
  save_db_config(self._cfg)
131
160
  self.accept()
132
161
 
@@ -155,14 +184,18 @@ class SettingsDialog(QDialog):
155
184
  @Slot(bool)
156
185
  def save_key_btn_clicked(self, checked: bool):
157
186
  if checked:
158
- p1 = KeyPrompt(
159
- self, title="Enter your key", message="Enter the encryption key"
160
- )
161
- if p1.exec() != QDialog.Accepted:
162
- return
163
- self.key = p1.key()
164
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
165
- save_db_config(self._cfg)
187
+ if not self.key:
188
+ p1 = KeyPrompt(
189
+ self, title="Enter your key", message="Enter the encryption key"
190
+ )
191
+ if p1.exec() != QDialog.Accepted:
192
+ self.save_key_btn.blockSignals(True)
193
+ self.save_key_btn.setChecked(False)
194
+ self.save_key_btn.blockSignals(False)
195
+ return
196
+ self.key = p1.key() or ""
197
+ else:
198
+ self.key = ""
166
199
 
167
200
  @property
168
201
  def config(self) -> DBConfig:
bouquin/toolbar.py CHANGED
@@ -15,6 +15,7 @@ class ToolBar(QToolBar):
15
15
  bulletsRequested = Signal()
16
16
  numbersRequested = Signal()
17
17
  alignRequested = Signal(Qt.AlignmentFlag)
18
+ historyRequested = Signal()
18
19
 
19
20
  def __init__(self, parent=None):
20
21
  super().__init__("Format", parent)
@@ -76,6 +77,10 @@ class ToolBar(QToolBar):
76
77
  lambda: self.alignRequested.emit(Qt.AlignRight)
77
78
  )
78
79
 
80
+ # History button
81
+ self.actHistory = QAction("History", self)
82
+ self.actHistory.triggered.connect(self.historyRequested)
83
+
79
84
  self.addActions(
80
85
  [
81
86
  self.actBold,
@@ -92,6 +97,7 @@ class ToolBar(QToolBar):
92
97
  self.actAlignL,
93
98
  self.actAlignC,
94
99
  self.actAlignR,
100
+ self.actHistory,
95
101
  ]
96
102
  )
97
103
 
@@ -120,6 +126,9 @@ class ToolBar(QToolBar):
120
126
  self._style_letter_button(self.actAlignC, "C")
121
127
  self._style_letter_button(self.actAlignR, "R")
122
128
 
129
+ # History
130
+ self._style_letter_button(self.actHistory, "View History")
131
+
123
132
  def _style_letter_button(
124
133
  self,
125
134
  action: QAction,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
@@ -46,6 +46,7 @@ There is deliberately no network connectivity or syncing intended.
46
46
  * Search
47
47
  * Automatic periodic saving (or explicitly save)
48
48
  * Transparent integrity checking of the database when it opens
49
+ * Automatic locking of the app after a period of inactivity (default 15 min)
49
50
  * Rekey the database (change the password)
50
51
  * Export the database to json, txt, html or csv
51
52
 
@@ -0,0 +1,18 @@
1
+ bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
2
+ bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
+ bouquin/db.py,sha256=GDcv_ngezbf9DiFUhrEFN9yq4i2QHuDepG-2qQLyD80,16082
4
+ bouquin/editor.py,sha256=vPLqysUNinUO6gtJQ8uDxJ_BL-lcaq0IXLStlG63k4E,8042
5
+ bouquin/history_dialog.py,sha256=oFW1CGyqT8mzXdYuVyuBrlNVFSkvlBzajkOJ590L0go,6308
6
+ bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
7
+ bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
8
+ bouquin/main_window.py,sha256=su_3MXwogKNRRcovng7Ll3btoTEdkD8OZMMK7UqmPsg,22199
9
+ bouquin/save_dialog.py,sha256=nPLNWeImJZamNg53qE7_aeMK_p16aOiry0G4VvJsIWY,939
10
+ bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
11
+ bouquin/settings.py,sha256=GpMeJcTjdL1PFumeqdlSOi7nlgGdPTOeRbFadWYFcA0,870
12
+ bouquin/settings_dialog.py,sha256=pgIg2G5O092mPn5EmkKrEgtl-Tyc8dwwCyNSNEAOidA,7256
13
+ bouquin/toolbar.py,sha256=AsJB5zpPzUIyZkOqGcb2EJqQXelmPZ5aJX35XrrxA4g,5694
14
+ bouquin-0.1.5.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
15
+ bouquin-0.1.5.dist-info/METADATA,sha256=SM2UsBP9iC21_2YAglG50fbz_CoZ91qg6m16L8GIJmU,2546
16
+ bouquin-0.1.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
17
+ bouquin-0.1.5.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
18
+ bouquin-0.1.5.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
2
- bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
- bouquin/db.py,sha256=s3FDphbi6zxpHEFHnz44saZ9qAV4wU4WEiE6V95PkmI,7877
4
- bouquin/editor.py,sha256=vPLqysUNinUO6gtJQ8uDxJ_BL-lcaq0IXLStlG63k4E,8042
5
- bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
6
- bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
7
- bouquin/main_window.py,sha256=48lq5trwORpGWko6jWLGBk-_7PrtaQfZT1l-jbz67rY,15427
8
- bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
9
- bouquin/settings.py,sha256=aEsIIlYGwSxCVXXMpo98192QzatIIP6OvQDtcKrYWW4,742
10
- bouquin/settings_dialog.py,sha256=kWR4OeeHd5uQZ6lfHtuYx3UIh_MCb-nhjHcDyhQhpKM,5747
11
- bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
12
- bouquin-0.1.3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
- bouquin-0.1.3.dist-info/METADATA,sha256=y2FvqLWDTEYj1E2LCYAezvRsbycKVV71pVy9AaZv9EY,2468
14
- bouquin-0.1.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
15
- bouquin-0.1.3.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
16
- bouquin-0.1.3.dist-info/RECORD,,