bouquin 0.1.10__py3-none-any.whl → 0.1.12__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.
bouquin/db.py CHANGED
@@ -257,68 +257,31 @@ class DBManager:
257
257
  ).fetchall()
258
258
  return [dict(r) for r in rows]
259
259
 
260
- def get_version(
261
- self,
262
- *,
263
- date_iso: str | None = None,
264
- version_no: int | None = None,
265
- version_id: int | None = None,
266
- ) -> dict | None:
260
+ def get_version(self, *, version_id: int) -> dict | None:
267
261
  """
268
- Fetch a specific version by (date, version_no) OR by version_id.
262
+ Fetch a specific version by version_id.
269
263
  Returns a dict with keys: id, date, version_no, created_at, note, content.
270
264
  """
271
265
  cur = self.conn.cursor()
272
- if version_id is not None:
273
- row = cur.execute(
274
- "SELECT id, date, version_no, created_at, note, content "
275
- "FROM versions WHERE id=?;",
276
- (version_id,),
277
- ).fetchone()
278
- else:
279
- if date_iso is None or version_no is None:
280
- raise ValueError(
281
- "Provide either version_id OR (date_iso and version_no)"
282
- )
283
- row = cur.execute(
284
- "SELECT id, date, version_no, created_at, note, content "
285
- "FROM versions WHERE date=? AND version_no=?;",
286
- (date_iso, version_no),
287
- ).fetchone()
266
+ row = cur.execute(
267
+ "SELECT id, date, version_no, created_at, note, content "
268
+ "FROM versions WHERE id=?;",
269
+ (version_id,),
270
+ ).fetchone()
288
271
  return dict(row) if row else None
289
272
 
290
- def revert_to_version(
291
- self,
292
- date_iso: str,
293
- *,
294
- version_no: int | None = None,
295
- version_id: int | None = None,
296
- ) -> None:
273
+ def revert_to_version(self, date_iso: str, version_id: int) -> None:
297
274
  """
298
275
  Point the page head (pages.current_version_id) to an existing version.
299
- Fast revert: no content is rewritten.
300
276
  """
301
- if self.conn is None:
302
- raise RuntimeError("Database is not connected")
303
277
  cur = self.conn.cursor()
304
278
 
305
- if version_id is None:
306
- if version_no is None:
307
- raise ValueError("Provide version_no or version_id")
308
- row = cur.execute(
309
- "SELECT id FROM versions WHERE date=? AND version_no=?;",
310
- (date_iso, version_no),
311
- ).fetchone()
312
- if row is None:
313
- raise ValueError("Version not found for this date")
314
- version_id = int(row["id"])
315
- else:
316
- # Ensure that version_id belongs to the given date
317
- row = cur.execute(
318
- "SELECT date FROM versions WHERE id=?;", (version_id,)
319
- ).fetchone()
320
- if row is None or row["date"] != date_iso:
321
- raise ValueError("version_id does not belong to the given date")
279
+ # Ensure that version_id belongs to the given date
280
+ row = cur.execute(
281
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
282
+ ).fetchone()
283
+ if row is None or row["date"] != date_iso:
284
+ raise ValueError("version_id does not belong to the given date")
322
285
 
323
286
  with self.conn:
324
287
  cur.execute(
@@ -342,20 +305,18 @@ class DBManager:
342
305
  ).fetchall()
343
306
  return [(r[0], r[1]) for r in rows]
344
307
 
345
- def export_json(
346
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
347
- ) -> None:
308
+ def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
348
309
  """
349
310
  Export to json.
350
311
  """
351
312
  data = [{"date": d, "content": c} for d, c in entries]
352
313
  with open(file_path, "w", encoding="utf-8") as f:
353
- if pretty:
354
- json.dump(data, f, ensure_ascii=False, indent=2)
355
- else:
356
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
314
+ json.dump(data, f, ensure_ascii=False, indent=2)
357
315
 
358
316
  def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
317
+ """
318
+ Export pages to CSV.
319
+ """
359
320
  # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
360
321
  with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
361
322
  writer = csv.writer(f)
@@ -369,6 +330,10 @@ class DBManager:
369
330
  separator: str = "\n\n— — — — —\n\n",
370
331
  strip_html: bool = True,
371
332
  ) -> None:
333
+ """
334
+ Strip the HTML from the latest version of the pages
335
+ and save to a text file.
336
+ """
372
337
  import re, html as _html
373
338
 
374
339
  # Precompiled patterns
@@ -407,6 +372,9 @@ class DBManager:
407
372
  def export_html(
408
373
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
409
374
  ) -> None:
375
+ """
376
+ Export to HTML with a heading.
377
+ """
410
378
  parts = [
411
379
  "<!doctype html>",
412
380
  '<html lang="en">',
@@ -429,6 +397,10 @@ class DBManager:
429
397
  def export_markdown(
430
398
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
431
399
  ) -> None:
400
+ """
401
+ Export to HTML, similar to export_html, but then convert to Markdown
402
+ using markdownify, and finally save to file.
403
+ """
432
404
  parts = [
433
405
  "<!doctype html>",
434
406
  '<html lang="en">',
@@ -469,6 +441,10 @@ class DBManager:
469
441
  cur.execute("DETACH DATABASE backup")
470
442
 
471
443
  def export_by_extension(self, file_path: str) -> None:
444
+ """
445
+ Fallback catch-all that runs one of the above functions based on
446
+ the extension of the file name that was chosen by the user.
447
+ """
472
448
  entries = self.get_all_entries()
473
449
  ext = os.path.splitext(file_path)[1].lower()
474
450
 
@@ -480,6 +456,10 @@ class DBManager:
480
456
  self.export_txt(entries, file_path)
481
457
  elif ext in {".html", ".htm"}:
482
458
  self.export_html(entries, file_path)
459
+ elif ext in {".sql", ".sqlite"}:
460
+ self.export_sql(file_path)
461
+ elif ext == ".md":
462
+ self.export_markdown(entries, file_path)
483
463
  else:
484
464
  raise ValueError(f"Unsupported extension: {ext}")
485
465
 
bouquin/editor.py CHANGED
@@ -68,13 +68,161 @@ class Editor(QTextEdit):
68
68
  self._retint_anchors_to_palette()
69
69
 
70
70
  self._themes = theme_manager
71
+ self._apply_code_theme() # set initial code colors
71
72
  # Refresh on theme change
72
73
  self._themes.themeChanged.connect(self._on_theme_changed)
74
+ self._themes.themeChanged.connect(
75
+ lambda _t: QTimer.singleShot(0, self._apply_code_theme)
76
+ )
73
77
 
74
78
  self._linkifying = False
75
79
  self.textChanged.connect(self._linkify_document)
76
80
  self.viewport().setMouseTracking(True)
77
81
 
82
+ # ---------------- Helpers ---------------- #
83
+
84
+ def _iter_frames(self, root=None):
85
+ """Depth-first traversal of all frames (including root if passed)."""
86
+ doc = self.document()
87
+ stack = [root or doc.rootFrame()]
88
+ while stack:
89
+ f = stack.pop()
90
+ yield f
91
+ it = f.begin()
92
+ while not it.atEnd():
93
+ cf = it.currentFrame()
94
+ if cf is not None:
95
+ stack.append(cf)
96
+ it += 1
97
+
98
+ def _is_code_frame(self, frame, tolerant: bool = False) -> bool:
99
+ """
100
+ True if 'frame' is a code frame.
101
+ - tolerant=False: require our property marker
102
+ - tolerant=True: also accept legacy background or non-wrapping heuristic
103
+ """
104
+ ff = frame.frameFormat()
105
+ if ff.property(self._CODE_FRAME_PROP):
106
+ return True
107
+ if not tolerant:
108
+ return False
109
+
110
+ # Background colour check
111
+ bg = ff.background()
112
+ if bg.style() != Qt.NoBrush:
113
+ c = bg.color()
114
+ if c.isValid():
115
+ if (
116
+ abs(c.red() - 245) <= 2
117
+ and abs(c.green() - 245) <= 2
118
+ and abs(c.blue() - 245) <= 2
119
+ ):
120
+ return True
121
+ if (
122
+ abs(c.red() - 43) <= 2
123
+ and abs(c.green() - 43) <= 2
124
+ and abs(c.blue() - 43) <= 2
125
+ ):
126
+ return True
127
+
128
+ # Heuristic: mostly non-wrapping blocks
129
+ doc = self.document()
130
+ bc = QTextCursor(doc)
131
+ bc.setPosition(frame.firstPosition())
132
+ blocks = codeish = 0
133
+ while bc.position() < frame.lastPosition():
134
+ b = bc.block()
135
+ if not b.isValid():
136
+ break
137
+ blocks += 1
138
+ if b.blockFormat().nonBreakableLines():
139
+ codeish += 1
140
+ bc.setPosition(b.position() + b.length())
141
+ return blocks > 0 and (codeish / blocks) >= 0.6
142
+
143
+ def _nearest_code_frame(self, cursor, tolerant: bool = False):
144
+ """Walk up parents from the cursor and return the first code frame."""
145
+ f = cursor.currentFrame()
146
+ while f:
147
+ if self._is_code_frame(f, tolerant=tolerant):
148
+ return f
149
+ f = f.parentFrame()
150
+ return None
151
+
152
+ def _code_block_formats(self, fg: QColor | None = None):
153
+ """(QTextBlockFormat, QTextCharFormat) for code blocks."""
154
+ mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
155
+
156
+ bf = QTextBlockFormat()
157
+ bf.setTopMargin(0)
158
+ bf.setBottomMargin(0)
159
+ bf.setLeftMargin(12)
160
+ bf.setRightMargin(12)
161
+ bf.setNonBreakableLines(True)
162
+
163
+ cf = QTextCharFormat()
164
+ cf.setFont(mono)
165
+ cf.setFontFixedPitch(True)
166
+ if fg is not None:
167
+ cf.setForeground(fg)
168
+ return bf, cf
169
+
170
+ def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat:
171
+ """Standard frame format for code blocks."""
172
+ ff = QTextFrameFormat()
173
+ ff.setBackground(bg)
174
+ ff.setPadding(6)
175
+ ff.setBorder(0)
176
+ ff.setLeftMargin(0)
177
+ ff.setRightMargin(0)
178
+ ff.setTopMargin(0)
179
+ ff.setBottomMargin(0)
180
+ ff.setProperty(self._CODE_FRAME_PROP, True)
181
+ return ff
182
+
183
+ def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None):
184
+ """Apply background to frame and standard code formats to all blocks inside."""
185
+ ff = frame.frameFormat()
186
+ ff.setBackground(bg)
187
+ frame.setFrameFormat(ff)
188
+
189
+ bf, cf = self._code_block_formats(fg)
190
+ doc = self.document()
191
+ bc = QTextCursor(doc)
192
+ bc.setPosition(frame.firstPosition())
193
+ while bc.position() < frame.lastPosition():
194
+ bc.select(QTextCursor.BlockUnderCursor)
195
+ bc.mergeBlockFormat(bf)
196
+ bc.mergeBlockCharFormat(cf)
197
+ if not bc.movePosition(QTextCursor.NextBlock):
198
+ break
199
+
200
+ def _safe_block_insertion_cursor(self):
201
+ """
202
+ Return a cursor positioned for inserting an inline object (like an image):
203
+ - not inside a code frame (moves to after frame if necessary)
204
+ - at a fresh paragraph (inserts a block if mid-line)
205
+ Also updates the editor's current cursor to that position.
206
+ """
207
+ c = QTextCursor(self.textCursor())
208
+ frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only
209
+ if frame:
210
+ out = QTextCursor(self.document())
211
+ out.setPosition(frame.lastPosition())
212
+ self.setTextCursor(out)
213
+ c = self.textCursor()
214
+ if c.positionInBlock() != 0:
215
+ c.insertBlock()
216
+ return c
217
+
218
+ def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage:
219
+ """If the image is wider than viewport*ratio, scale it down proportionally."""
220
+ if self.viewport():
221
+ max_w = int(self.viewport().width() * ratio)
222
+ if img.width() > max_w:
223
+ return img.scaledToWidth(max_w, Qt.SmoothTransformation)
224
+ return img
225
+
78
226
  def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
79
227
  return abs(float(a) - float(b)) <= eps
80
228
 
@@ -91,16 +239,35 @@ class Editor(QTextEdit):
91
239
  nf.setFontWeight(QFont.Weight.Normal)
92
240
  self.mergeCurrentCharFormat(nf)
93
241
 
94
- def _find_code_frame(self, cursor=None):
95
- """Return the nearest ancestor frame that's one of our code frames, else None."""
96
- if cursor is None:
97
- cursor = self.textCursor()
98
- f = cursor.currentFrame()
99
- while f:
100
- if f.frameFormat().property(self._CODE_FRAME_PROP):
101
- return f
102
- f = f.parentFrame()
103
- return None
242
+ def _code_theme_colors(self):
243
+ """Return (bg, fg) for code blocks based on the effective palette."""
244
+ pal = QApplication.instance().palette()
245
+ # simple luminance check on the window color
246
+ win = pal.color(QPalette.Window)
247
+ is_dark = win.value() < 128
248
+ if is_dark:
249
+ bg = QColor(43, 43, 43) # dark code background
250
+ fg = pal.windowText().color() # readable on dark
251
+ else:
252
+ bg = QColor(245, 245, 245) # light code background
253
+ fg = pal.text().color() # readable on light
254
+ return bg, fg
255
+
256
+ def _apply_code_theme(self):
257
+ """Retint all code frames (even those reloaded from HTML) to match the current theme."""
258
+ bg, fg = self._code_theme_colors()
259
+ self._CODE_BG = bg # used by future apply_code() calls
260
+
261
+ doc = self.document()
262
+ cur = QTextCursor(doc)
263
+ cur.beginEditBlock()
264
+ try:
265
+ for f in self._iter_frames(doc.rootFrame()):
266
+ if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True):
267
+ self._retint_code_frame(f, bg, fg)
268
+ finally:
269
+ cur.endEditBlock()
270
+ self.viewport().update()
104
271
 
105
272
  def _trim_url_end(self, url: str) -> str:
106
273
  # strip common trailing punctuation not part of the URL
@@ -149,7 +316,7 @@ class Editor(QTextEdit):
149
316
  fmt.setFontUnderline(True)
150
317
  fmt.setForeground(self.palette().brush(QPalette.Link))
151
318
 
152
- cur.mergeCharFormat(fmt) # merge so we dont clobber other styling
319
+ cur.mergeCharFormat(fmt) # merge so we don't clobber other styling
153
320
 
154
321
  cur.endEditBlock()
155
322
  finally:
@@ -209,26 +376,12 @@ class Editor(QTextEdit):
209
376
  html = html.replace(f"src='{old}'", f"src='{data_url}'")
210
377
  return html
211
378
 
212
- def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
213
- c = self.textCursor()
214
-
215
- # Don’t drop inside a code frame
216
- frame = self._find_code_frame(c)
217
- if frame:
218
- out = QTextCursor(self.document())
219
- out.setPosition(frame.lastPosition())
220
- self.setTextCursor(out)
221
- c = self.textCursor()
222
-
223
- # Start a fresh paragraph if mid-line
224
- if c.positionInBlock() != 0:
225
- c.insertBlock()
226
-
227
- if autoscale and self.viewport():
228
- max_w = int(self.viewport().width() * 0.92)
229
- if img.width() > max_w:
230
- img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
379
+ # ---------------- Image insertion & sizing (DRY’d) ---------------- #
231
380
 
381
+ def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
382
+ c = self._safe_block_insertion_cursor()
383
+ if autoscale:
384
+ img = self._scale_to_viewport(img)
232
385
  c.insertImage(img)
233
386
  c.insertBlock() # one blank line after the image
234
387
 
@@ -338,6 +491,8 @@ class Editor(QTextEdit):
338
491
  return
339
492
  self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
340
493
 
494
+ # ---------------- Context menu ---------------- #
495
+
341
496
  def contextMenuEvent(self, e):
342
497
  menu = self.createStandardContextMenu()
343
498
  tc, imgfmt, orig = self._image_info_at_cursor()
@@ -351,6 +506,8 @@ class Editor(QTextEdit):
351
506
  sub.addAction("Reset to original", self._reset_image_size)
352
507
  menu.exec(e.globalPos())
353
508
 
509
+ # ---------------- Clipboard / DnD ---------------- #
510
+
354
511
  def insertFromMimeData(self, source):
355
512
  # 1) Direct image from clipboard
356
513
  if source.hasImage():
@@ -398,7 +555,7 @@ class Editor(QTextEdit):
398
555
  data = base64.b64decode(m.group(1))
399
556
  img = QImage.fromData(data)
400
557
  if not img.isNull():
401
- self._insert_qimage_at_cursor(self, img, autoscale=True)
558
+ self._insert_qimage_at_cursor(img, autoscale=True)
402
559
  return
403
560
  except Exception:
404
561
  pass # fall through
@@ -412,19 +569,7 @@ class Editor(QTextEdit):
412
569
  Insert one or more images at the cursor. Large images can be auto-scaled
413
570
  to fit the viewport width while preserving aspect ratio.
414
571
  """
415
- c = self.textCursor()
416
-
417
- # Avoid dropping images inside a code frame
418
- frame = self._find_code_frame(c)
419
- if frame:
420
- out = QTextCursor(self.document())
421
- out.setPosition(frame.lastPosition())
422
- self.setTextCursor(out)
423
- c = self.textCursor()
424
-
425
- # Ensure there's a paragraph break if we're mid-line
426
- if c.positionInBlock() != 0:
427
- c.insertBlock()
572
+ c = self._safe_block_insertion_cursor()
428
573
 
429
574
  for path in paths:
430
575
  reader = QImageReader(path)
@@ -432,14 +577,14 @@ class Editor(QTextEdit):
432
577
  if img.isNull():
433
578
  continue
434
579
 
435
- if autoscale and self.viewport():
436
- max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
437
- if img.width() > max_w:
438
- img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
580
+ if autoscale:
581
+ img = self._scale_to_viewport(img)
439
582
 
440
583
  c.insertImage(img)
441
584
  c.insertBlock() # put each image on its own line
442
585
 
586
+ # ---------------- Mouse & key handling ---------------- #
587
+
443
588
  def mouseReleaseEvent(self, e):
444
589
  if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
445
590
  href = self.anchorAt(e.pos())
@@ -510,7 +655,7 @@ class Editor(QTextEdit):
510
655
 
511
656
  # If we're on an empty line inside a code frame, consume Enter and jump out
512
657
  if c.block().length() == 1:
513
- frame = self._find_code_frame(c)
658
+ frame = self._nearest_code_frame(c, tolerant=False)
514
659
  if frame:
515
660
  out = QTextCursor(self.document())
516
661
  out.setPosition(frame.lastPosition()) # after the frame's contents
@@ -588,7 +733,7 @@ class Editor(QTextEdit):
588
733
 
589
734
  # ====== Checkbox core ======
590
735
  def _base_point_size_for_block(self, block) -> float:
591
- # Try the blocks char format, then editor font
736
+ # Try the block's char format, then editor font
592
737
  sz = block.charFormat().fontPointSize()
593
738
  if sz <= 0:
594
739
  sz = self.fontPointSize()
@@ -697,14 +842,6 @@ class Editor(QTextEdit):
697
842
  break
698
843
  b = b.next()
699
844
 
700
- def toggle_current_checkbox_state(self):
701
- """Tick/untick the current line if it starts with a checkbox."""
702
- b = self.textCursor().block()
703
- state, _ = self._checkbox_info_for_block(b)
704
- if state is None:
705
- return
706
- self._set_block_checkbox_state(b, not state)
707
-
708
845
  @Slot()
709
846
  def apply_weight(self):
710
847
  cur = self.currentCharFormat()
@@ -744,46 +881,16 @@ class Editor(QTextEdit):
744
881
  if not c.hasSelection():
745
882
  c.select(QTextCursor.BlockUnderCursor)
746
883
 
747
- # Wrap the selection in a single frame (no per-block padding/margins).
748
- ff = QTextFrameFormat()
749
- ff.setBackground(self._CODE_BG)
750
- ff.setPadding(6) # visual padding for the WHOLE block
751
- ff.setBorder(0)
752
- ff.setLeftMargin(0)
753
- ff.setRightMargin(0)
754
- ff.setTopMargin(0)
755
- ff.setBottomMargin(0)
756
- ff.setProperty(self._CODE_FRAME_PROP, True)
757
-
758
- mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
884
+ ff = self._new_code_frame_format(self._CODE_BG)
759
885
 
760
886
  c.beginEditBlock()
761
887
  try:
762
888
  c.insertFrame(ff) # with a selection, this wraps the selection
763
889
 
764
- # Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
765
- frame = self._find_code_frame(c)
766
- bc = QTextCursor(self.document())
767
- bc.setPosition(frame.firstPosition())
768
-
769
- while bc.position() < frame.lastPosition():
770
- bc.select(QTextCursor.BlockUnderCursor)
771
-
772
- bf = QTextBlockFormat()
773
- bf.setTopMargin(0)
774
- bf.setBottomMargin(0)
775
- bf.setLeftMargin(12)
776
- bf.setRightMargin(12)
777
- bf.setNonBreakableLines(True)
778
-
779
- cf = QTextCharFormat()
780
- cf.setFont(mono)
781
- cf.setFontFixedPitch(True)
782
-
783
- bc.mergeBlockFormat(bf)
784
- bc.mergeBlockCharFormat(cf)
785
-
786
- bc.setPosition(bc.block().position() + bc.block().length())
890
+ # Format all blocks inside the new frame (keep fg=None on creation)
891
+ frame = self._nearest_code_frame(c, tolerant=False)
892
+ if frame:
893
+ self._retint_code_frame(frame, self._CODE_BG, fg=None)
787
894
  finally:
788
895
  c.endEditBlock()
789
896
 
@@ -855,6 +962,7 @@ class Editor(QTextEdit):
855
962
  def _on_theme_changed(self, _theme: Theme):
856
963
  # Defer one event-loop tick so widgets have the new palette
857
964
  QTimer.singleShot(0, self._retint_anchors_to_palette)
965
+ QTimer.singleShot(0, self._apply_code_theme)
858
966
 
859
967
  @Slot()
860
968
  def _retint_anchors_to_palette(self, *_):
@@ -874,10 +982,13 @@ class Editor(QTextEdit):
874
982
  if fmt.isAnchor():
875
983
  new_fmt = QTextCharFormat(fmt)
876
984
  new_fmt.setForeground(link_brush) # force palette link color
877
- cur.setPosition(frag.position())
878
- cur.setPosition(
879
- frag.position() + frag.length(), QTextCursor.KeepAnchor
880
- )
985
+ start = frag.position()
986
+ cur.setPosition(start)
987
+ cur.movePosition(
988
+ QTextCursor.NextCharacter,
989
+ QTextCursor.KeepAnchor,
990
+ frag.length(),
991
+ ) # select exactly this fragment
881
992
  cur.setCharFormat(new_fmt)
882
993
  it += 1
883
994
  block = block.next()
@@ -895,3 +1006,4 @@ class Editor(QTextEdit):
895
1006
 
896
1007
  # Ensure anchors adopt the palette color on startup
897
1008
  self._retint_anchors_to_palette()
1009
+ self._apply_code_theme()
bouquin/find_bar.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtGui import (
5
+ QShortcut,
6
+ QTextCursor,
7
+ QTextCharFormat,
8
+ QTextDocument,
9
+ )
10
+ from PySide6.QtWidgets import (
11
+ QWidget,
12
+ QHBoxLayout,
13
+ QLineEdit,
14
+ QLabel,
15
+ QPushButton,
16
+ QCheckBox,
17
+ QTextEdit,
18
+ )
19
+
20
+
21
+ class FindBar(QWidget):
22
+ """Widget for finding text in the Editor"""
23
+
24
+ closed = (
25
+ Signal()
26
+ ) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
27
+
28
+ def __init__(
29
+ self,
30
+ editor: QTextEdit,
31
+ shortcut_parent: QWidget | None = None,
32
+ parent: QWidget | None = None,
33
+ ):
34
+ super().__init__(parent)
35
+ self.editor = editor
36
+
37
+ # UI
38
+ layout = QHBoxLayout(self)
39
+ layout.setContentsMargins(6, 0, 6, 0)
40
+
41
+ layout.addWidget(QLabel("Find:"))
42
+ self.edit = QLineEdit(self)
43
+ self.edit.setPlaceholderText("Type to search…")
44
+ layout.addWidget(self.edit)
45
+
46
+ self.case = QCheckBox("Match case", self)
47
+ layout.addWidget(self.case)
48
+
49
+ self.prevBtn = QPushButton("Prev", self)
50
+ self.nextBtn = QPushButton("Next", self)
51
+ self.closeBtn = QPushButton("✕", self)
52
+ self.closeBtn.setFlat(True)
53
+ layout.addWidget(self.prevBtn)
54
+ layout.addWidget(self.nextBtn)
55
+ layout.addWidget(self.closeBtn)
56
+
57
+ self.setVisible(False)
58
+
59
+ # Shortcut escape key to close findBar
60
+ sp = shortcut_parent if shortcut_parent is not None else (parent or self)
61
+ self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
62
+
63
+ # Signals
64
+ self.edit.returnPressed.connect(self.find_next)
65
+ self.edit.textChanged.connect(self._update_highlight)
66
+ self.case.toggled.connect(self._update_highlight)
67
+ self.nextBtn.clicked.connect(self.find_next)
68
+ self.prevBtn.clicked.connect(self.find_prev)
69
+ self.closeBtn.clicked.connect(self.hide_bar)
70
+
71
+ # ----- Public API -----
72
+
73
+ def show_bar(self):
74
+ """Show the bar, seed with current selection if sensible, focus the line edit."""
75
+ tc = self.editor.textCursor()
76
+ sel = tc.selectedText().strip()
77
+ if sel and "\u2029" not in sel: # ignore multi-paragraph selections
78
+ self.edit.setText(sel)
79
+ self.setVisible(True)
80
+ self.edit.setFocus(Qt.ShortcutFocusReason)
81
+ self.edit.selectAll()
82
+ self._update_highlight()
83
+
84
+ def hide_bar(self):
85
+ self.setVisible(False)
86
+ self._clear_highlight()
87
+ self.closed.emit()
88
+
89
+ def refresh(self):
90
+ """Recompute highlights"""
91
+ self._update_highlight()
92
+
93
+ # ----- Internals -----
94
+
95
+ def _maybe_hide(self):
96
+ if self.isVisible():
97
+ self.hide_bar()
98
+
99
+ def _flags(self, backward: bool = False) -> QTextDocument.FindFlags:
100
+ flags = QTextDocument.FindFlags()
101
+ if backward:
102
+ flags |= QTextDocument.FindBackward
103
+ if self.case.isChecked():
104
+ flags |= QTextDocument.FindCaseSensitively
105
+ return flags
106
+
107
+ def find_next(self):
108
+ txt = self.edit.text()
109
+ if not txt:
110
+ return
111
+ # If current selection == query, bump caret to the end so we don't re-match it.
112
+ c = self.editor.textCursor()
113
+ if c.hasSelection():
114
+ sel = c.selectedText()
115
+ same = (
116
+ (sel == txt)
117
+ if self.case.isChecked()
118
+ else (sel.casefold() == txt.casefold())
119
+ )
120
+ if same:
121
+ end = max(c.position(), c.anchor())
122
+ c.setPosition(end, QTextCursor.MoveAnchor)
123
+ self.editor.setTextCursor(c)
124
+ if not self.editor.find(txt, self._flags(False)):
125
+ cur = self.editor.textCursor()
126
+ cur.movePosition(QTextCursor.Start)
127
+ self.editor.setTextCursor(cur)
128
+ self.editor.find(txt, self._flags(False))
129
+ self.editor.ensureCursorVisible()
130
+ self._update_highlight()
131
+
132
+ def find_prev(self):
133
+ txt = self.edit.text()
134
+ if not txt:
135
+ return
136
+ # If current selection == query, bump caret to the start so we don't re-match it.
137
+ c = self.editor.textCursor()
138
+ if c.hasSelection():
139
+ sel = c.selectedText()
140
+ same = (
141
+ (sel == txt)
142
+ if self.case.isChecked()
143
+ else (sel.casefold() == txt.casefold())
144
+ )
145
+ if same:
146
+ start = min(c.position(), c.anchor())
147
+ c.setPosition(start, QTextCursor.MoveAnchor)
148
+ self.editor.setTextCursor(c)
149
+ if not self.editor.find(txt, self._flags(True)):
150
+ cur = self.editor.textCursor()
151
+ cur.movePosition(QTextCursor.End)
152
+ self.editor.setTextCursor(cur)
153
+ self.editor.find(txt, self._flags(True))
154
+ self.editor.ensureCursorVisible()
155
+ self._update_highlight()
156
+
157
+ def _update_highlight(self):
158
+ txt = self.edit.text()
159
+ if not txt:
160
+ self._clear_highlight()
161
+ return
162
+
163
+ doc = self.editor.document()
164
+ flags = self._flags(False)
165
+ cur = QTextCursor(doc)
166
+ cur.movePosition(QTextCursor.Start)
167
+
168
+ fmt = QTextCharFormat()
169
+ hl = self.palette().highlight().color()
170
+ hl.setAlpha(90)
171
+ fmt.setBackground(hl)
172
+
173
+ selections = []
174
+ while True:
175
+ cur = doc.find(txt, cur, flags)
176
+ if cur.isNull():
177
+ break
178
+ sel = QTextEdit.ExtraSelection()
179
+ sel.cursor = cur
180
+ sel.format = fmt
181
+ selections.append(sel)
182
+
183
+ self.editor.setExtraSelections(selections)
184
+
185
+ def _clear_highlight(self):
186
+ self.editor.setExtraSelections([])
bouquin/history_dialog.py CHANGED
@@ -156,7 +156,7 @@ class HistoryDialog(QDialog):
156
156
  # Diff vs current (textual diff)
157
157
  cur = self._db.get_version(version_id=self._current_id)
158
158
  self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
159
- # Enable revert only if selecting a non-current
159
+ # Enable revert only if selecting a non-current version
160
160
  self.btn_revert.setEnabled(sel_id != self._current_id)
161
161
 
162
162
  @Slot()
@@ -167,7 +167,7 @@ class HistoryDialog(QDialog):
167
167
  sel_id = item.data(Qt.UserRole)
168
168
  if sel_id == self._current_id:
169
169
  return
170
- # Flip head pointer
170
+ # Flip head pointer to the older version
171
171
  try:
172
172
  self._db.revert_to_version(self._date, version_id=sel_id)
173
173
  except Exception as e:
bouquin/key_prompt.py CHANGED
@@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
17
17
  title: str = "Enter key",
18
18
  message: str = "Enter key",
19
19
  ):
20
+ """
21
+ Prompt the user for the key required to decrypt the database.
22
+
23
+ Used when opening the app, unlocking the idle locked screen,
24
+ or when rekeying.
25
+ """
20
26
  super().__init__(parent)
21
27
  self.setWindowTitle(title)
22
28
  v = QVBoxLayout(self)
bouquin/lock_overlay.py CHANGED
@@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
7
7
 
8
8
  class LockOverlay(QWidget):
9
9
  def __init__(self, parent: QWidget, on_unlock: callable):
10
+ """
11
+ Widget that 'locks' the screen after a configured idle time.
12
+ """
10
13
  super().__init__(parent)
11
14
  self.setObjectName("LockOverlay")
12
15
  self.setAttribute(Qt.WA_StyledBackground, True)
@@ -39,6 +42,9 @@ class LockOverlay(QWidget):
39
42
  self.hide()
40
43
 
41
44
  def _is_dark(self, pal: QPalette) -> bool:
45
+ """
46
+ Detect if dark mode is in use.
47
+ """
42
48
  c = pal.color(QPalette.Window)
43
49
  luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
44
50
  return luma < 0.5
@@ -58,7 +64,7 @@ class LockOverlay(QWidget):
58
64
 
59
65
  self.setStyleSheet(
60
66
  f"""
61
- #LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
67
+ #LockOverlay {{ background-color: rgb(0,0,0); }}
62
68
  #LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
63
69
 
64
70
  #LockOverlay QPushButton#unlockButton {{
@@ -113,7 +119,7 @@ class LockOverlay(QWidget):
113
119
 
114
120
  def changeEvent(self, ev):
115
121
  super().changeEvent(ev)
116
- # Only re-style on palette flips
122
+ # Only re-style on palette flips (user changed theme)
117
123
  if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
118
124
  self._apply_overlay_style()
119
125
 
bouquin/main_window.py CHANGED
@@ -24,8 +24,10 @@ from PySide6.QtGui import (
24
24
  QDesktopServices,
25
25
  QFont,
26
26
  QGuiApplication,
27
+ QKeySequence,
27
28
  QPalette,
28
29
  QTextCharFormat,
30
+ QTextCursor,
29
31
  QTextListFormat,
30
32
  )
31
33
  from PySide6.QtWidgets import (
@@ -43,6 +45,7 @@ from PySide6.QtWidgets import (
43
45
 
44
46
  from .db import DBManager
45
47
  from .editor import Editor
48
+ from .find_bar import FindBar
46
49
  from .history_dialog import HistoryDialog
47
50
  from .key_prompt import KeyPrompt
48
51
  from .lock_overlay import LockOverlay
@@ -121,7 +124,7 @@ class MainWindow(QMainWindow):
121
124
  split = QSplitter()
122
125
  split.addWidget(left_panel)
123
126
  split.addWidget(self.editor)
124
- split.setStretchFactor(1, 1) # editor grows
127
+ split.setStretchFactor(1, 1)
125
128
 
126
129
  container = QWidget()
127
130
  lay = QVBoxLayout(container)
@@ -146,8 +149,23 @@ class MainWindow(QMainWindow):
146
149
 
147
150
  QApplication.instance().installEventFilter(self)
148
151
 
152
+ # Focus on the editor
153
+ self.setFocusPolicy(Qt.StrongFocus)
154
+ self.editor.setFocusPolicy(Qt.StrongFocus)
155
+ self.toolBar.setFocusPolicy(Qt.NoFocus)
156
+ for w in self.toolBar.findChildren(QWidget):
157
+ w.setFocusPolicy(Qt.NoFocus)
158
+ QGuiApplication.instance().applicationStateChanged.connect(
159
+ self._on_app_state_changed
160
+ )
161
+
149
162
  # Status bar for feedback
150
163
  self.statusBar().showMessage("Ready", 800)
164
+ # Add findBar and add it to the statusBar
165
+ self.findBar = FindBar(self.editor, shortcut_parent=self, parent=self)
166
+ self.statusBar().addPermanentWidget(self.findBar)
167
+ # When the findBar closes, put the caret back in the editor
168
+ self.findBar.closed.connect(self._focus_editor_now)
151
169
 
152
170
  # Menu bar (File)
153
171
  mb = self.menuBar()
@@ -202,6 +220,24 @@ class MainWindow(QMainWindow):
202
220
  nav_menu.addAction(act_today)
203
221
  self.addAction(act_today)
204
222
 
223
+ act_find = QAction("Find on page", self)
224
+ act_find.setShortcut(QKeySequence.Find)
225
+ act_find.triggered.connect(self.findBar.show_bar)
226
+ nav_menu.addAction(act_find)
227
+ self.addAction(act_find)
228
+
229
+ act_find_next = QAction("Find Next", self)
230
+ act_find_next.setShortcut(QKeySequence.FindNext)
231
+ act_find_next.triggered.connect(self.findBar.find_next)
232
+ nav_menu.addAction(act_find_next)
233
+ self.addAction(act_find_next)
234
+
235
+ act_find_prev = QAction("Find Previous", self)
236
+ act_find_prev.setShortcut(QKeySequence.FindPrevious)
237
+ act_find_prev.triggered.connect(self.findBar.find_prev)
238
+ nav_menu.addAction(act_find_prev)
239
+ self.addAction(act_find_prev)
240
+
205
241
  # Help menu with drop-down
206
242
  help_menu = mb.addMenu("&Help")
207
243
  act_docs = QAction("Documentation", self)
@@ -281,7 +317,7 @@ class MainWindow(QMainWindow):
281
317
  if hasattr(self, "_lock_overlay"):
282
318
  self._lock_overlay._apply_overlay_style()
283
319
  self._apply_calendar_text_colors()
284
- self._apply_link_css() # Reapply link styles based on the current theme
320
+ self._apply_link_css()
285
321
  self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
286
322
  self.calendar.update()
287
323
  self.editor.viewport().update()
@@ -298,7 +334,6 @@ class MainWindow(QMainWindow):
298
334
  css = "" # Default to no custom styling for links (system or light theme)
299
335
 
300
336
  try:
301
- # Apply to the editor (QTextEdit or any other relevant widgets)
302
337
  self.editor.document().setDefaultStyleSheet(css)
303
338
  except Exception:
304
339
  pass
@@ -347,7 +382,6 @@ class MainWindow(QMainWindow):
347
382
  self.calendar.setPalette(app_pal)
348
383
  self.calendar.setStyleSheet("")
349
384
 
350
- # Keep weekend text color in sync with the current palette
351
385
  self._apply_calendar_text_colors()
352
386
  self.calendar.update()
353
387
 
@@ -483,18 +517,16 @@ class MainWindow(QMainWindow):
483
517
  # Inject the extra_data before the closing </body></html>
484
518
  modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
485
519
  text = modified
486
- self.editor.setHtml(text)
520
+ # Force a save now so we don't lose it.
521
+ self._set_editor_html_preserve_view(text)
487
522
  self._dirty = True
488
523
  self._save_date(date_iso, True)
489
524
 
490
- print("end")
491
525
  except Exception as e:
492
526
  QMessageBox.critical(self, "Read Error", str(e))
493
527
  return
494
528
 
495
- self.editor.blockSignals(True)
496
- self.editor.setHtml(text)
497
- self.editor.blockSignals(False)
529
+ self._set_editor_html_preserve_view(text)
498
530
 
499
531
  self._dirty = False
500
532
  # track which date the editor currently represents
@@ -708,7 +740,7 @@ class MainWindow(QMainWindow):
708
740
  QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
709
741
  )
710
742
  r = screen.availableGeometry()
711
- # Center the window in that screens available area
743
+ # Center the window in that screen's available area
712
744
  self.move(r.center() - self.rect().center())
713
745
 
714
746
  # ----------------- Export handler ----------------- #
@@ -837,7 +869,7 @@ If you want an encrypted backup, choose Backup instead of Export.
837
869
  return
838
870
  if minutes == 0:
839
871
  self._idle_timer.stop()
840
- # If you’re currently locked, unlock when user disables the timer:
872
+ # If currently locked, unlock when user disables the timer:
841
873
  if getattr(self, "_locked", False):
842
874
  try:
843
875
  self._locked = False
@@ -853,9 +885,14 @@ If you want an encrypted backup, choose Backup instead of Export.
853
885
  def eventFilter(self, obj, event):
854
886
  if event.type() == QEvent.KeyPress and not self._locked:
855
887
  self._idle_timer.start()
888
+ if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
889
+ QTimer.singleShot(0, self._focus_editor_now)
856
890
  return super().eventFilter(obj, event)
857
891
 
858
892
  def _enter_lock(self):
893
+ """
894
+ Trigger the lock overlay and disable widgets
895
+ """
859
896
  if self._locked:
860
897
  return
861
898
  self._locked = True
@@ -871,6 +908,10 @@ If you want an encrypted backup, choose Backup instead of Export.
871
908
 
872
909
  @Slot()
873
910
  def _on_unlock_clicked(self):
911
+ """
912
+ Prompt for key to unlock screen
913
+ If successful, re-enable widgets
914
+ """
874
915
  try:
875
916
  ok = self._prompt_for_key_until_valid(first_time=False)
876
917
  except Exception as e:
@@ -887,6 +928,7 @@ If you want an encrypted backup, choose Backup instead of Export.
887
928
  if tb:
888
929
  tb.setEnabled(True)
889
930
  self._idle_timer.start()
931
+ QTimer.singleShot(0, self._focus_editor_now)
890
932
 
891
933
  # ----------------- Close handlers ----------------- #
892
934
  def closeEvent(self, event):
@@ -902,3 +944,65 @@ If you want an encrypted backup, choose Backup instead of Export.
902
944
  except Exception:
903
945
  pass
904
946
  super().closeEvent(event)
947
+
948
+ # ----------------- Below logic helps focus the editor ----------------- #
949
+
950
+ def _focus_editor_now(self):
951
+ """Give focus to the editor and ensure the caret is visible."""
952
+ if getattr(self, "_locked", False):
953
+ return
954
+ if not self.isActiveWindow():
955
+ return
956
+ # Belt-and-suspenders: do it now and once more on the next tick
957
+ self.editor.setFocus(Qt.ActiveWindowFocusReason)
958
+ self.editor.ensureCursorVisible()
959
+ QTimer.singleShot(
960
+ 0,
961
+ lambda: (
962
+ self.editor.setFocus(Qt.ActiveWindowFocusReason),
963
+ self.editor.ensureCursorVisible(),
964
+ ),
965
+ )
966
+
967
+ def _on_app_state_changed(self, state):
968
+ # Called on macOS/Wayland/Windows when the whole app re-activates
969
+ if state == Qt.ApplicationActive and self.isActiveWindow():
970
+ QTimer.singleShot(0, self._focus_editor_now)
971
+
972
+ def changeEvent(self, ev):
973
+ # Called on some platforms when the window's activation state flips
974
+ super().changeEvent(ev)
975
+ if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
976
+ QTimer.singleShot(0, self._focus_editor_now)
977
+
978
+ def _set_editor_html_preserve_view(self, html: str):
979
+ ed = self.editor
980
+
981
+ # Save caret/selection and scroll
982
+ cur = ed.textCursor()
983
+ old_pos, old_anchor = cur.position(), cur.anchor()
984
+ v = ed.verticalScrollBar().value()
985
+ h = ed.horizontalScrollBar().value()
986
+
987
+ # Only touch the doc if it actually changed
988
+ ed.blockSignals(True)
989
+ if ed.toHtml() != html:
990
+ ed.setHtml(html)
991
+ ed.blockSignals(False)
992
+
993
+ # Restore scroll first
994
+ ed.verticalScrollBar().setValue(v)
995
+ ed.horizontalScrollBar().setValue(h)
996
+
997
+ # Restore caret/selection
998
+ cur = ed.textCursor()
999
+ cur.setPosition(old_anchor)
1000
+ mode = (
1001
+ QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
1002
+ )
1003
+ cur.setPosition(old_pos, mode)
1004
+ ed.setTextCursor(cur)
1005
+
1006
+ # Refresh highlights if the theme changed
1007
+ if hasattr(self, "findBar"):
1008
+ self.findBar.refresh()
bouquin/save_dialog.py CHANGED
@@ -18,6 +18,9 @@ class SaveDialog(QDialog):
18
18
  title: str = "Enter a name for this version",
19
19
  message: str = "Enter a name for this version?",
20
20
  ):
21
+ """
22
+ Used for explicitly saving a new version of a page.
23
+ """
21
24
  super().__init__(parent)
22
25
  self.setWindowTitle(title)
23
26
  v = QVBoxLayout(self)
bouquin/search.py CHANGED
@@ -70,7 +70,6 @@ class Search(QWidget):
70
70
  try:
71
71
  rows: Iterable[Row] = self._db.search_entries(q)
72
72
  except Exception:
73
- # be quiet on DB errors here; caller can surface if desired
74
73
  rows = []
75
74
 
76
75
  self._populate_results(q, rows)
@@ -246,7 +246,7 @@ class SettingsDialog(QDialog):
246
246
  )
247
247
 
248
248
  save_db_config(self._cfg)
249
- self.parent().themes.apply(selected_theme)
249
+ self.parent().themes.set(selected_theme)
250
250
  self.accept()
251
251
 
252
252
  def _change_key(self):
bouquin/theme.py CHANGED
@@ -49,7 +49,7 @@ class ThemeManager(QObject):
49
49
  scheme = getattr(hints, "colorScheme", None)
50
50
  if callable(scheme):
51
51
  scheme = hints.colorScheme()
52
- # 0=Light, 1=Dark in newer Qt; fall back to Light
52
+ # 0=Light, 1=Dark; fall back to Light
53
53
  theme = Theme.DARK if scheme == 1 else Theme.LIGHT
54
54
 
55
55
  # Always use Fusion so palette applies consistently cross-platform
@@ -58,7 +58,6 @@ class ThemeManager(QObject):
58
58
  if theme == Theme.DARK:
59
59
  pal = self._dark_palette()
60
60
  self._app.setPalette(pal)
61
- # keep stylesheet empty unless you need widget-specific tweaks
62
61
  self._app.setStyleSheet("")
63
62
  else:
64
63
  pal = self._light_palette()
bouquin/toolbar.py CHANGED
@@ -140,6 +140,11 @@ class ToolBar(QToolBar):
140
140
  for a in (self.actAlignL, self.actAlignC, self.actAlignR):
141
141
  a.setActionGroup(self.grpAlign)
142
142
 
143
+ self.grpLists = QActionGroup(self)
144
+ self.grpLists.setExclusive(True)
145
+ for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
146
+ a.setActionGroup(self.grpLists)
147
+
143
148
  # Add actions
144
149
  self.addActions(
145
150
  [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.10
3
+ Version: 0.1.12
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
@@ -38,6 +38,8 @@ There is deliberately no network connectivity or syncing intended.
38
38
 
39
39
  ![Screenshot of Bouquin](./screenshot.png)
40
40
 
41
+ ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
42
+
41
43
  ## Features
42
44
 
43
45
  * Data is encrypted at rest
@@ -51,8 +53,11 @@ There is deliberately no network connectivity or syncing intended.
51
53
  * Transparent integrity checking of the database when it opens
52
54
  * Automatic locking of the app after a period of inactivity (default 15 min)
53
55
  * Rekey the database (change the password)
54
- * Export the database to json, txt, html, csv or .sql (for sqlite3)
56
+ * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
55
57
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
58
+ * Dark and light themes
59
+ * Automatically generate checkboxes when typing 'TODO'
60
+ * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
56
61
 
57
62
 
58
63
  ## How to install
@@ -79,5 +84,5 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
79
84
  * Clone the repo
80
85
  * Ensure you have poetry installed
81
86
  * Run `poetry install --with test`
82
- * Run `poetry run pytest -vvvv --cov=bouquin`
87
+ * Run `./tests.sh`
83
88
 
@@ -0,0 +1,21 @@
1
+ bouquin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
+ bouquin/db.py,sha256=b39sofZZe4Uw54quYgjcWeF6jNFe0xekBT7fpIP5ySs,16910
4
+ bouquin/editor.py,sha256=62p6yoXsqCdlKiwgatjnD606lpsPbynlcnifT03gHW0,36529
5
+ bouquin/find_bar.py,sha256=RwBbQzfgBXDefpACQoheHH1XJNH-w08Snhk1_o2OmD4,5783
6
+ bouquin/history_dialog.py,sha256=PcbKxR-xXxPryydWK5MRMmOC0Zc8SUcoPSkktMdFuFo,6204
7
+ bouquin/key_prompt.py,sha256=oQhLDOQv1QUr_ImA9Zu78JkDpVqPbZZJdhu0c_5Cq5U,1266
8
+ bouquin/lock_overlay.py,sha256=d1xoBMx2CSNk0zP5V6k65UqJCC4aiIrwNlfDld49ymA,4197
9
+ bouquin/main.py,sha256=lBOMS7THgHb4CAJVj8NRYABtNAEez9jlL0wI1oOtfT4,611
10
+ bouquin/main_window.py,sha256=xJzArxoFBpGshzB0Lgg570YhDSVtCbfnEmm_wr3EFwc,37703
11
+ bouquin/save_dialog.py,sha256=YUkZ8kL1hja15D8qv68yY2zPyjBAJZsDQbp6Y6EvDbA,1023
12
+ bouquin/search.py,sha256=qOPxW8Q8bb6Y0Yf0D0w5wBbPw4wVLvvwrymTeJWEWJc,7027
13
+ bouquin/settings.py,sha256=F3WLkk2G_By3ppZsRbrnq3PtL2Zav7aA-mIegvGTc8Y,1128
14
+ bouquin/settings_dialog.py,sha256=YQHYjn3y2sgJGtkkApADxAodojDfLvnZYeQmynXLkos,10699
15
+ bouquin/theme.py,sha256=6ODq9oKLAk7lnvW9uGRMIIjfhf70SgPivLYh3ZN671M,3489
16
+ bouquin/toolbar.py,sha256=ejjFLZGa5UFldvI01ragkQtA7fTMwYPA3TjRfSZT06A,8301
17
+ bouquin-0.1.12.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
18
+ bouquin-0.1.12.dist-info/METADATA,sha256=MyNgd331GomD5IzWWY8oVWbU1Ez08bPSq6XGD4CU6Hw,3064
19
+ bouquin-0.1.12.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
20
+ bouquin-0.1.12.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
21
+ bouquin-0.1.12.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- bouquin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
- bouquin/db.py,sha256=Ukh37u397QS_BuH-BJOJUF_F9Q9t3iCOBIf7O4Tqims,17699
4
- bouquin/editor.py,sha256=IkczFheXi5QMUGWqFpdRD1VQxzKRWAusUZz_sP29vV8,32301
5
- bouquin/history_dialog.py,sha256=Z3BO60HD-vO9KQ84s0ccpzVWs0X898GzzHfjqARQPqg,6175
6
- bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
7
- bouquin/lock_overlay.py,sha256=8Q9NG8ejaGRZ6Tmc5uB6532JWgb3lLO5TQj718CraPA,4052
8
- bouquin/main.py,sha256=lBOMS7THgHb4CAJVj8NRYABtNAEez9jlL0wI1oOtfT4,611
9
- bouquin/main_window.py,sha256=am6EksRbExZQjOlyFMK7-TEwf5w2NCtgFhMVGbWXXvw,33853
10
- bouquin/save_dialog.py,sha256=nPLNWeImJZamNg53qE7_aeMK_p16aOiry0G4VvJsIWY,939
11
- bouquin/search.py,sha256=6ygbXck21iwA3RUM6yLIuxUr7AsLI4UYOc7H30XwsZw,7099
12
- bouquin/settings.py,sha256=F3WLkk2G_By3ppZsRbrnq3PtL2Zav7aA-mIegvGTc8Y,1128
13
- bouquin/settings_dialog.py,sha256=apqCjrvKsZWZrMxDTF0EK-6Dehw6tvUgXdVzmkMxsM0,10701
14
- bouquin/theme.py,sha256=rjiAJCjoJbKrsDJmbTPxWFLv_WxzPfWJB08bj5cNW7I,3576
15
- bouquin/toolbar.py,sha256=3FH-hNdOD64C6v78IdFTf4nAdtEdOWnqhMrs4ZYdEow,8099
16
- bouquin-0.1.10.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
- bouquin-0.1.10.dist-info/METADATA,sha256=3C8lmSuzl006oXC7oaCN5_7R0tAu0y5UdbKV3h5aDHo,2848
18
- bouquin-0.1.10.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
19
- bouquin-0.1.10.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
20
- bouquin-0.1.10.dist-info/RECORD,,