bouquin 0.3__tar.gz → 0.3.1__tar.gz

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.
Files changed (29) hide show
  1. {bouquin-0.3 → bouquin-0.3.1}/PKG-INFO +2 -2
  2. {bouquin-0.3 → bouquin-0.3.1}/README.md +1 -1
  3. {bouquin-0.3 → bouquin-0.3.1}/bouquin/db.py +146 -16
  4. {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/en.json +16 -1
  5. {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/fr.json +1 -0
  6. {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/it.json +1 -0
  7. {bouquin-0.3 → bouquin-0.3.1}/bouquin/main_window.py +18 -8
  8. {bouquin-0.3 → bouquin-0.3.1}/bouquin/markdown_editor.py +60 -1
  9. {bouquin-0.3 → bouquin-0.3.1}/bouquin/markdown_highlighter.py +19 -0
  10. bouquin-0.3.1/bouquin/statistics_dialog.py +294 -0
  11. {bouquin-0.3 → bouquin-0.3.1}/bouquin/tag_browser.py +22 -4
  12. {bouquin-0.3 → bouquin-0.3.1}/pyproject.toml +1 -1
  13. {bouquin-0.3 → bouquin-0.3.1}/LICENSE +0 -0
  14. {bouquin-0.3 → bouquin-0.3.1}/bouquin/__init__.py +0 -0
  15. {bouquin-0.3 → bouquin-0.3.1}/bouquin/__main__.py +0 -0
  16. {bouquin-0.3 → bouquin-0.3.1}/bouquin/find_bar.py +0 -0
  17. {bouquin-0.3 → bouquin-0.3.1}/bouquin/flow_layout.py +0 -0
  18. {bouquin-0.3 → bouquin-0.3.1}/bouquin/history_dialog.py +0 -0
  19. {bouquin-0.3 → bouquin-0.3.1}/bouquin/key_prompt.py +0 -0
  20. {bouquin-0.3 → bouquin-0.3.1}/bouquin/lock_overlay.py +0 -0
  21. {bouquin-0.3 → bouquin-0.3.1}/bouquin/main.py +0 -0
  22. {bouquin-0.3 → bouquin-0.3.1}/bouquin/save_dialog.py +0 -0
  23. {bouquin-0.3 → bouquin-0.3.1}/bouquin/search.py +0 -0
  24. {bouquin-0.3 → bouquin-0.3.1}/bouquin/settings.py +0 -0
  25. {bouquin-0.3 → bouquin-0.3.1}/bouquin/settings_dialog.py +0 -0
  26. {bouquin-0.3 → bouquin-0.3.1}/bouquin/strings.py +0 -0
  27. {bouquin-0.3 → bouquin-0.3.1}/bouquin/tags_widget.py +0 -0
  28. {bouquin-0.3 → bouquin-0.3.1}/bouquin/theme.py +0 -0
  29. {bouquin-0.3 → bouquin-0.3.1}/bouquin/toolbar.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.3
3
+ Version: 0.3.1
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
@@ -54,7 +54,7 @@ There is deliberately no network connectivity or syncing intended.
54
54
  * Transparent integrity checking of the database when it opens
55
55
  * Automatic locking of the app after a period of inactivity (default 15 min)
56
56
  * Rekey the database (change the password)
57
- * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
57
+ * Export the database to json, html, csv, markdown or .sql (for sqlite3)
58
58
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
59
59
  * Dark and light themes
60
60
  * Automatically generate checkboxes when typing 'TODO'
@@ -34,7 +34,7 @@ There is deliberately no network connectivity or syncing intended.
34
34
  * Transparent integrity checking of the database when it opens
35
35
  * Automatic locking of the app after a period of inactivity (default 15 min)
36
36
  * Rekey the database (change the password)
37
- * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
37
+ * Export the database to json, html, csv, markdown or .sql (for sqlite3)
38
38
  * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
39
39
  * Dark and light themes
40
40
  * Automatically generate checkboxes when typing 'TODO'
@@ -1,14 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import csv
4
+ import datetime as _dt
4
5
  import hashlib
5
6
  import html
6
7
  import json
8
+ import re
7
9
 
8
10
  from dataclasses import dataclass
9
11
  from pathlib import Path
10
12
  from sqlcipher3 import dbapi2 as sqlite
11
- from typing import List, Sequence, Tuple
13
+ from typing import List, Sequence, Tuple, Dict
12
14
 
13
15
 
14
16
  from . import strings
@@ -354,21 +356,6 @@ class DBManager:
354
356
  writer.writerow(["date", "content"]) # header
355
357
  writer.writerows(entries)
356
358
 
357
- def export_txt(
358
- self,
359
- entries: Sequence[Entry],
360
- file_path: str,
361
- separator: str = "\n\n— — — — —\n\n",
362
- ) -> None:
363
- """
364
- Strip the the latest version of the pages to a text file.
365
- """
366
- with open(file_path, "w", encoding="utf-8") as f:
367
- for i, (d, c) in enumerate(entries):
368
- f.write(f"{d}\n{c}\n")
369
- if i < len(entries) - 1:
370
- f.write(separator)
371
-
372
359
  def export_html(
373
360
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
374
361
  ) -> None:
@@ -561,6 +548,30 @@ class DBManager:
561
548
  ).fetchall()
562
549
  return [(r[0], r[1], r[2]) for r in rows]
563
550
 
551
+ def add_tag(self, name: str, color: str) -> None:
552
+ """
553
+ Update a tag's name and colour.
554
+ """
555
+ name = name.strip()
556
+ color = color.strip() or "#CCCCCC"
557
+
558
+ try:
559
+ with self.conn:
560
+ cur = self.conn.cursor()
561
+ cur.execute(
562
+ """
563
+ INSERT INTO tags
564
+ (name, color)
565
+ VALUES (?, ?);
566
+ """,
567
+ (name, color),
568
+ )
569
+ except sqlite.IntegrityError as e:
570
+ if "UNIQUE constraint failed: tags.name" in str(e):
571
+ raise sqlite.IntegrityError(
572
+ strings._("tag_already_exists_with_that_name")
573
+ ) from e
574
+
564
575
  def update_tag(self, tag_id: int, name: str, color: str) -> None:
565
576
  """
566
577
  Update a tag's name and colour.
@@ -616,6 +627,125 @@ class DBManager:
616
627
  ).fetchall()
617
628
  return [(r[0], r[1]) for r in rows]
618
629
 
630
+ # ---------- helpers for word counting ----------
631
+ def _strip_markdown(self, text: str) -> str:
632
+ """
633
+ Cheap markdown-ish stripper for word counting.
634
+ We only need approximate numbers.
635
+ """
636
+ if not text:
637
+ return ""
638
+
639
+ # Remove fenced code blocks
640
+ text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
641
+ # Remove inline code
642
+ text = re.sub(r"`[^`]+`", " ", text)
643
+ # [text](url) → text
644
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
645
+ # Remove emphasis markers, headings, etc.
646
+ text = re.sub(r"[#*_>]+", " ", text)
647
+ # Strip simple HTML tags
648
+ text = re.sub(r"<[^>]+>", " ", text)
649
+
650
+ return text
651
+
652
+ def _count_words(self, text: str) -> int:
653
+ text = self._strip_markdown(text)
654
+ words = re.findall(r"\b\w+\b", text, flags=re.UNICODE)
655
+ return len(words)
656
+
657
+ def gather_stats(self):
658
+ """Compute all the numbers the Statistics dialog needs in one place."""
659
+
660
+ # 1) pages with content (current version only)
661
+ try:
662
+ pages_with_content_list = self.dates_with_content()
663
+ except Exception:
664
+ pages_with_content_list = []
665
+ pages_with_content = len(pages_with_content_list)
666
+
667
+ cur = self.conn.cursor()
668
+
669
+ # 2 & 3) total revisions + page with most revisions + per-date counts
670
+ total_revisions = 0
671
+ page_most_revisions = None
672
+ page_most_revisions_count = 0
673
+ revisions_by_date: Dict[_dt.date, int] = {}
674
+
675
+ rows = cur.execute(
676
+ """
677
+ SELECT date, COUNT(*) AS c
678
+ FROM versions
679
+ GROUP BY date
680
+ ORDER BY date;
681
+ """
682
+ ).fetchall()
683
+
684
+ for r in rows:
685
+ date_iso = r["date"]
686
+ c = int(r["c"])
687
+ total_revisions += c
688
+
689
+ if c > page_most_revisions_count:
690
+ page_most_revisions_count = c
691
+ page_most_revisions = date_iso
692
+
693
+ try:
694
+ d = _dt.date.fromisoformat(date_iso)
695
+ revisions_by_date[d] = c
696
+ except ValueError:
697
+ # Ignore malformed dates
698
+ pass
699
+
700
+ # 4) total words + per-date words (current version only)
701
+ entries = self.get_all_entries()
702
+ total_words = 0
703
+ words_by_date: Dict[_dt.date, int] = {}
704
+
705
+ for date_iso, content in entries:
706
+ wc = self._count_words(content or "")
707
+ total_words += wc
708
+ try:
709
+ d = _dt.date.fromisoformat(date_iso)
710
+ words_by_date[d] = wc
711
+ except ValueError:
712
+ pass
713
+
714
+ # tags + page with most tags
715
+
716
+ rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
717
+ unique_tags = int(rows[0]["total_unique"]) if rows else 0
718
+
719
+ rows = cur.execute(
720
+ """
721
+ SELECT page_date, COUNT(*) AS c
722
+ FROM page_tags
723
+ GROUP BY page_date
724
+ ORDER BY c DESC, page_date ASC
725
+ LIMIT 1;
726
+ """
727
+ ).fetchall()
728
+
729
+ if rows:
730
+ page_most_tags = rows[0]["page_date"]
731
+ page_most_tags_count = int(rows[0]["c"])
732
+ else:
733
+ page_most_tags = None
734
+ page_most_tags_count = 0
735
+
736
+ return (
737
+ pages_with_content,
738
+ total_revisions,
739
+ page_most_revisions,
740
+ page_most_revisions_count,
741
+ words_by_date,
742
+ total_words,
743
+ unique_tags,
744
+ page_most_tags,
745
+ page_most_tags_count,
746
+ revisions_by_date,
747
+ )
748
+
619
749
  def close(self) -> None:
620
750
  if self.conn is not None:
621
751
  self.conn.close()
@@ -114,6 +114,7 @@
114
114
  "tags": "Tags",
115
115
  "tag": "Tag",
116
116
  "manage_tags": "Manage tags",
117
+ "main_window_manage_tags_accessible_flag": "Manage &Tags",
117
118
  "add_tag_placeholder": "Add a tag and press Enter",
118
119
  "tag_browser_title": "Tag Browser",
119
120
  "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
@@ -127,10 +128,24 @@
127
128
  "add": "Add",
128
129
  "remove": "Remove",
129
130
  "ok": "OK",
131
+ "add_a_tag": "Add a tag",
130
132
  "edit_tag_name": "Edit tag name",
131
133
  "new_tag_name": "New tag name:",
132
134
  "change_color": "Change colour",
133
135
  "delete_tag": "Delete tag",
134
136
  "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
135
- "tag_already_exists_with_that_name": "A tag already exists with that name"
137
+ "tag_already_exists_with_that_name": "A tag already exists with that name",
138
+ "statistics": "Statistics",
139
+ "main_window_statistics_accessible_flag": "Stat&istics",
140
+ "stats_pages_with_content": "Pages with content (current version)",
141
+ "stats_total_revisions": "Total revisions",
142
+ "stats_page_most_revisions": "Page with most revisions",
143
+ "stats_total_words": "Total words (current versions)",
144
+ "stats_unique_tags": "Unique tags",
145
+ "stats_page_most_tags": "Page with most tags",
146
+ "stats_activity_heatmap": "Activity heatmap",
147
+ "stats_heatmap_metric": "Colour by",
148
+ "stats_metric_words": "Words",
149
+ "stats_metric_revisions": "Revisions",
150
+ "stats_no_data": "No statistics available yet."
136
151
  }
@@ -127,6 +127,7 @@
127
127
  "add": "Ajouter",
128
128
  "remove": "Supprimer",
129
129
  "ok": "OK",
130
+ "add_a_tag": "Ajouter une étiquette",
130
131
  "edit_tag_name": "Modifier le nom de l'étiquette",
131
132
  "new_tag_name": "Nouveau nom de l'étiquette :",
132
133
  "change_color": "Changer la couleur",
@@ -126,6 +126,7 @@
126
126
  "add": "Aggiungi",
127
127
  "remove": "Rimuovi",
128
128
  "ok": "OK",
129
+ "add_a_tag": "Aggiungi un tag",
129
130
  "edit_tag_name": "Modifica nome tag",
130
131
  "new_tag_name": "Nuovo nome tag:",
131
132
  "change_color": "Cambia colore",
@@ -55,6 +55,7 @@ from .save_dialog import SaveDialog
55
55
  from .search import Search
56
56
  from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
57
57
  from .settings_dialog import SettingsDialog
58
+ from .statistics_dialog import StatisticsDialog
58
59
  from . import strings
59
60
  from .tags_widget import PageTagsWidget
60
61
  from .toolbar import ToolBar
@@ -209,10 +210,14 @@ class MainWindow(QMainWindow):
209
210
  act_backup.setShortcut("Ctrl+Shift+B")
210
211
  act_backup.triggered.connect(self._backup)
211
212
  file_menu.addAction(act_backup)
212
- act_tags = QAction("&" + strings._("manage_tags"), self)
213
+ act_tags = QAction(strings._("main_window_manage_tags_accessible_flag"), self)
213
214
  act_tags.setShortcut("Ctrl+T")
214
215
  act_tags.triggered.connect(self.tags._open_manager)
215
216
  file_menu.addAction(act_tags)
217
+ act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self)
218
+ act_stats.setShortcut("Shift+Ctrl+S")
219
+ act_stats.triggered.connect(self._open_statistics)
220
+ file_menu.addAction(act_stats)
216
221
  file_menu.addSeparator()
217
222
  act_quit = QAction("&" + strings._("quit"), self)
218
223
  act_quit.setShortcut("Ctrl+Q")
@@ -435,6 +440,7 @@ class MainWindow(QMainWindow):
435
440
  return self._create_new_tab(date)
436
441
 
437
442
  def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor:
443
+ """Create a new editor tab and return the editor instance."""
438
444
  if date is None:
439
445
  date = self.calendar.selectedDate()
440
446
 
@@ -444,7 +450,6 @@ class MainWindow(QMainWindow):
444
450
  self.tab_widget.setCurrentIndex(existing)
445
451
  return self.tab_widget.widget(existing)
446
452
 
447
- """Create a new editor tab and return the editor instance."""
448
453
  editor = MarkdownEditor(self.themes)
449
454
 
450
455
  # Set up the editor's event connections
@@ -1124,6 +1129,15 @@ class MainWindow(QMainWindow):
1124
1129
  self._load_selected_date()
1125
1130
  self._refresh_calendar_marks()
1126
1131
 
1132
+ # ------------ Statistics handler --------------- #
1133
+
1134
+ def _open_statistics(self):
1135
+ if not getattr(self, "db", None) or self.db.conn is None:
1136
+ return
1137
+
1138
+ dlg = StatisticsDialog(self.db, self)
1139
+ dlg.exec()
1140
+
1127
1141
  # ------------ Window positioning --------------- #
1128
1142
  def _restore_window_position(self):
1129
1143
  geom = self.settings.value("main/geometry", None)
@@ -1174,7 +1188,6 @@ class MainWindow(QMainWindow):
1174
1188
  return False
1175
1189
 
1176
1190
  filters = (
1177
- "Text (*.txt);;"
1178
1191
  "JSON (*.json);;"
1179
1192
  "CSV (*.csv);;"
1180
1193
  "HTML (*.html);;"
@@ -1190,22 +1203,19 @@ class MainWindow(QMainWindow):
1190
1203
  return # user cancelled
1191
1204
 
1192
1205
  default_ext = {
1193
- "Text (*.txt)": ".txt",
1194
1206
  "JSON (*.json)": ".json",
1195
1207
  "CSV (*.csv)": ".csv",
1196
1208
  "HTML (*.html)": ".html",
1197
1209
  "Markdown (*.md)": ".md",
1198
1210
  "SQL (*.sql)": ".sql",
1199
- }.get(selected_filter, ".txt")
1211
+ }.get(selected_filter, ".md")
1200
1212
 
1201
1213
  if not Path(filename).suffix:
1202
1214
  filename += default_ext
1203
1215
 
1204
1216
  try:
1205
1217
  entries = self.db.get_all_entries()
1206
- if selected_filter.startswith("Text"):
1207
- self.db.export_txt(entries, filename)
1208
- elif selected_filter.startswith("JSON"):
1218
+ if selected_filter.startswith("JSON"):
1209
1219
  self.db.export_json(entries, filename)
1210
1220
  elif selected_filter.startswith("CSV"):
1211
1221
  self.db.export_csv(entries, filename)
@@ -14,8 +14,9 @@ from PySide6.QtGui import (
14
14
  QTextFormat,
15
15
  QTextBlockFormat,
16
16
  QTextImageFormat,
17
+ QDesktopServices,
17
18
  )
18
- from PySide6.QtCore import Qt, QRect, QTimer
19
+ from PySide6.QtCore import Qt, QRect, QTimer, QUrl
19
20
  from PySide6.QtWidgets import QTextEdit
20
21
 
21
22
  from .theme import ThemeManager
@@ -70,6 +71,11 @@ class MarkdownEditor(QTextEdit):
70
71
 
71
72
  # Enable mouse tracking for checkbox clicking
72
73
  self.viewport().setMouseTracking(True)
74
+ # Also mark links as mouse-accessible
75
+ flags = self.textInteractionFlags()
76
+ self.setTextInteractionFlags(
77
+ flags | Qt.TextInteractionFlag.LinksAccessibleByMouse
78
+ )
73
79
 
74
80
  def setDocument(self, doc):
75
81
  super().setDocument(doc)
@@ -400,6 +406,28 @@ class MarkdownEditor(QTextEdit):
400
406
 
401
407
  return (None, "")
402
408
 
409
+ def _url_at_pos(self, pos) -> str | None:
410
+ """
411
+ Return the URL under the given widget position, or None if there isn't one.
412
+ """
413
+ cursor = self.cursorForPosition(pos)
414
+ block = cursor.block()
415
+ text = block.text()
416
+ if not text:
417
+ return None
418
+
419
+ # Position of the cursor inside this block
420
+ pos_in_block = cursor.position() - block.position()
421
+
422
+ # Same pattern as in MarkdownHighlighter
423
+ url_pattern = re.compile(r"(https?://[^\s<>()]+)")
424
+ for m in url_pattern.finditer(text):
425
+ start, end = m.span(1)
426
+ if start <= pos_in_block < end:
427
+ return m.group(1)
428
+
429
+ return None
430
+
403
431
  def keyPressEvent(self, event):
404
432
  """Handle special key events for markdown editing."""
405
433
 
@@ -622,6 +650,37 @@ class MarkdownEditor(QTextEdit):
622
650
  # Default handling
623
651
  super().keyPressEvent(event)
624
652
 
653
+ def mouseMoveEvent(self, event):
654
+ # Change cursor when hovering a link
655
+ url = self._url_at_pos(event.pos())
656
+ if url:
657
+ self.viewport().setCursor(Qt.PointingHandCursor)
658
+ else:
659
+ self.viewport().setCursor(Qt.IBeamCursor)
660
+
661
+ super().mouseMoveEvent(event)
662
+
663
+ def mouseReleaseEvent(self, event):
664
+ # Let QTextEdit handle caret/selection first
665
+ super().mouseReleaseEvent(event)
666
+
667
+ if event.button() != Qt.LeftButton:
668
+ return
669
+
670
+ # If the user dragged to select text, don't treat it as a click
671
+ if self.textCursor().hasSelection():
672
+ return
673
+
674
+ url_str = self._url_at_pos(event.pos())
675
+ if not url_str:
676
+ return
677
+
678
+ url = QUrl(url_str)
679
+ if not url.scheme():
680
+ url.setScheme("https")
681
+
682
+ QDesktopServices.openUrl(url)
683
+
625
684
  def mousePressEvent(self, event):
626
685
  """Toggle a checkbox only when the click lands on its icon."""
627
686
  if event.button() == Qt.LeftButton:
@@ -91,6 +91,13 @@ class MarkdownHighlighter(QSyntaxHighlighter):
91
91
  self.h3_format.setFontPointSize(14.0)
92
92
  self.h3_format.setFontWeight(QFont.Weight.Bold)
93
93
 
94
+ # Hyperlinks
95
+ self.link_format = QTextCharFormat()
96
+ link_color = pal.color(QPalette.Link)
97
+ self.link_format.setForeground(link_color)
98
+ self.link_format.setFontUnderline(True)
99
+ self.link_format.setAnchor(True)
100
+
94
101
  # Markdown syntax (the markers themselves) - make invisible
95
102
  self.syntax_format = QTextCharFormat()
96
103
  # Make the markers invisible by setting font size to 0.1 points
@@ -243,3 +250,15 @@ class MarkdownHighlighter(QSyntaxHighlighter):
243
250
  self.setFormat(start, 1, self.syntax_format)
244
251
  self.setFormat(end - 1, 1, self.syntax_format)
245
252
  self.setFormat(content_start, content_end - content_start, self.code_format)
253
+
254
+ # Hyperlinks
255
+ url_pattern = re.compile(r"(https?://[^\s<>()]+)")
256
+ for m in url_pattern.finditer(text):
257
+ start, end = m.span(1)
258
+ url = m.group(1)
259
+
260
+ # Clone link format so we can attach a per-link href
261
+ fmt = QTextCharFormat(self.link_format)
262
+ fmt.setAnchorHref(url)
263
+ # Overlay link attributes on top of whatever formatting is already there
264
+ self._overlay_range(start, end - start, fmt)
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _dt
4
+ from typing import Dict
5
+
6
+ from PySide6.QtCore import Qt, QSize
7
+ from PySide6.QtGui import QColor, QPainter, QPen, QBrush
8
+ from PySide6.QtWidgets import (
9
+ QDialog,
10
+ QVBoxLayout,
11
+ QFormLayout,
12
+ QLabel,
13
+ QGroupBox,
14
+ QHBoxLayout,
15
+ QComboBox,
16
+ QScrollArea,
17
+ QWidget,
18
+ QSizePolicy,
19
+ )
20
+
21
+ from . import strings
22
+ from .db import DBManager
23
+
24
+
25
+ # ---------- Activity heatmap ----------
26
+
27
+
28
+ class DateHeatmap(QWidget):
29
+ """
30
+ Small calendar heatmap for activity by date.
31
+
32
+ Data is a mapping: datetime.date -> integer value.
33
+ """
34
+
35
+ def __init__(self, parent=None):
36
+ super().__init__(parent)
37
+ self._data: Dict[_dt.date, int] = {}
38
+ self._start: _dt.date | None = None
39
+ self._end: _dt.date | None = None
40
+ self._max_value: int = 0
41
+
42
+ self._cell = 12
43
+ self._gap = 3
44
+ self._margin_left = 10
45
+ self._margin_top = 10
46
+ self._margin_bottom = 24
47
+ self._margin_right = 10
48
+
49
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
50
+
51
+ def set_data(self, data: Dict[_dt.date, int]) -> None:
52
+ """Replace dataset and recompute layout."""
53
+ self._data = {k: int(v) for k, v in (data or {}).items() if v is not None}
54
+ if not self._data:
55
+ self._start = self._end = None
56
+ self._max_value = 0
57
+ else:
58
+ earliest = min(self._data.keys())
59
+ latest = max(self._data.keys())
60
+ self._start = earliest - _dt.timedelta(days=earliest.weekday())
61
+ self._end = latest
62
+ self._max_value = max(self._data.values()) if self._data else 0
63
+
64
+ self.updateGeometry()
65
+ self.update()
66
+
67
+ # QWidget overrides ---------------------------------------------------
68
+
69
+ def sizeHint(self) -> QSize:
70
+ if not self._start or not self._end:
71
+ height = (
72
+ self._margin_top + self._margin_bottom + 7 * (self._cell + self._gap)
73
+ )
74
+ # some default width
75
+ width = (
76
+ self._margin_left + self._margin_right + 20 * (self._cell + self._gap)
77
+ )
78
+ return QSize(width, height)
79
+
80
+ day_count = (self._end - self._start).days + 1
81
+ weeks = (day_count + 6) // 7 # ceil
82
+
83
+ width = (
84
+ self._margin_left
85
+ + self._margin_right
86
+ + weeks * (self._cell + self._gap)
87
+ + self._gap
88
+ )
89
+ height = (
90
+ self._margin_top
91
+ + self._margin_bottom
92
+ + 7 * (self._cell + self._gap)
93
+ + self._gap
94
+ )
95
+ return QSize(width, height)
96
+
97
+ def minimumSizeHint(self) -> QSize:
98
+ sz = self.sizeHint()
99
+ return QSize(min(300, sz.width()), sz.height())
100
+
101
+ def paintEvent(self, event):
102
+ super().paintEvent(event)
103
+ painter = QPainter(self)
104
+ painter.setRenderHint(QPainter.Antialiasing, True)
105
+
106
+ if not self._start or not self._end:
107
+ return
108
+
109
+ palette = self.palette()
110
+ bg_no_data = palette.base().color()
111
+ active = palette.highlight().color()
112
+
113
+ painter.setPen(QPen(Qt.NoPen))
114
+
115
+ day_count = (self._end - self._start).days + 1
116
+ weeks = (day_count + 6) // 7
117
+
118
+ for week in range(weeks):
119
+ for dow in range(7):
120
+ idx = week * 7 + dow
121
+ date = self._start + _dt.timedelta(days=idx)
122
+ if date > self._end:
123
+ value = 0
124
+ else:
125
+ value = self._data.get(date, 0)
126
+
127
+ x = self._margin_left + week * (self._cell + self._gap)
128
+ y = self._margin_top + dow * (self._cell + self._gap)
129
+
130
+ if value <= 0 or self._max_value <= 0:
131
+ color = bg_no_data
132
+ else:
133
+ ratio = max(0.1, min(1.0, value / float(self._max_value)))
134
+ color = QColor(active)
135
+ # Lighter for low values, darker for high values
136
+ lighten = 150 - int(50 * ratio) # 150 ≈ light, 100 ≈ original
137
+ color = color.lighter(lighten)
138
+
139
+ painter.fillRect(
140
+ x,
141
+ y,
142
+ self._cell,
143
+ self._cell,
144
+ QBrush(color),
145
+ )
146
+
147
+ painter.setPen(palette.text().color())
148
+ fm = painter.fontMetrics()
149
+
150
+ prev_month = None
151
+ for week in range(weeks):
152
+ date = self._start + _dt.timedelta(days=week * 7)
153
+ if date > self._end:
154
+ break
155
+
156
+ if prev_month == date.month:
157
+ continue
158
+ prev_month = date.month
159
+
160
+ label = date.strftime("%b")
161
+
162
+ x_center = (
163
+ self._margin_left + week * (self._cell + self._gap) + self._cell / 2
164
+ )
165
+ y = self._margin_top + 7 * (self._cell + self._gap) + fm.ascent()
166
+
167
+ text_width = fm.horizontalAdvance(label)
168
+ painter.drawText(
169
+ int(x_center - text_width / 2),
170
+ int(y),
171
+ label,
172
+ )
173
+
174
+ painter.end()
175
+
176
+
177
+ # ---------- Statistics dialog itself ----------
178
+
179
+
180
+ class StatisticsDialog(QDialog):
181
+ """
182
+ Shows aggregate statistics and the date heatmap with a metric switcher.
183
+ """
184
+
185
+ def __init__(self, db: DBManager, parent=None):
186
+ super().__init__(parent)
187
+ self._db = db
188
+
189
+ self.setWindowTitle(strings._("statistics"))
190
+
191
+ root = QVBoxLayout(self)
192
+
193
+ (
194
+ pages_with_content,
195
+ total_revisions,
196
+ page_most_revisions,
197
+ page_most_revisions_count,
198
+ words_by_date,
199
+ total_words,
200
+ unique_tags,
201
+ page_most_tags,
202
+ page_most_tags_count,
203
+ revisions_by_date,
204
+ ) = self._gather_stats()
205
+
206
+ # --- Numeric summary at the top ----------------------------------
207
+ form = QFormLayout()
208
+ root.addLayout(form)
209
+
210
+ form.addRow(
211
+ strings._("stats_pages_with_content"),
212
+ QLabel(str(pages_with_content)),
213
+ )
214
+ form.addRow(
215
+ strings._("stats_total_revisions"),
216
+ QLabel(str(total_revisions)),
217
+ )
218
+
219
+ if page_most_revisions:
220
+ form.addRow(
221
+ strings._("stats_page_most_revisions"),
222
+ QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
223
+ )
224
+ else:
225
+ form.addRow(strings._("stats_page_most_revisions"), QLabel("—"))
226
+
227
+ form.addRow(
228
+ strings._("stats_total_words"),
229
+ QLabel(str(total_words)),
230
+ )
231
+
232
+ # Unique tag names
233
+ form.addRow(
234
+ strings._("stats_unique_tags"),
235
+ QLabel(str(unique_tags)),
236
+ )
237
+
238
+ if page_most_tags:
239
+ form.addRow(
240
+ strings._("stats_page_most_tags"),
241
+ QLabel(f"{page_most_tags} ({page_most_tags_count})"),
242
+ )
243
+ else:
244
+ form.addRow(strings._("stats_page_most_tags"), QLabel("—"))
245
+
246
+ # --- Heatmap with switcher ---------------------------------------
247
+ if words_by_date or revisions_by_date:
248
+ group = QGroupBox(strings._("stats_activity_heatmap"))
249
+ group_layout = QVBoxLayout(group)
250
+
251
+ # Metric selector
252
+ combo_row = QHBoxLayout()
253
+ combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
254
+ self.metric_combo = QComboBox()
255
+ self.metric_combo.addItem(strings._("stats_metric_words"), "words")
256
+ self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
257
+ combo_row.addWidget(self.metric_combo)
258
+ combo_row.addStretch(1)
259
+ group_layout.addLayout(combo_row)
260
+
261
+ self._heatmap = DateHeatmap()
262
+ self._words_by_date = words_by_date
263
+ self._revisions_by_date = revisions_by_date
264
+
265
+ scroll = QScrollArea()
266
+ scroll.setWidgetResizable(True)
267
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
268
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
269
+ scroll.setWidget(self._heatmap)
270
+ group_layout.addWidget(scroll)
271
+
272
+ root.addWidget(group)
273
+
274
+ # Default to "words"
275
+ self._apply_metric("words")
276
+ self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
277
+ else:
278
+ root.addWidget(QLabel(strings._("stats_no_data")))
279
+
280
+ # ---------- internal helpers ----------
281
+
282
+ def _apply_metric(self, metric: str) -> None:
283
+ if metric == "revisions":
284
+ self._heatmap.set_data(self._revisions_by_date)
285
+ else:
286
+ self._heatmap.set_data(self._words_by_date)
287
+
288
+ def _on_metric_changed(self, index: int) -> None:
289
+ metric = self.metric_combo.currentData()
290
+ if metric:
291
+ self._apply_metric(metric)
292
+
293
+ def _gather_stats(self):
294
+ return self._db.gather_stats()
@@ -1,4 +1,3 @@
1
- # tag_browser.py
2
1
  from PySide6.QtCore import Qt, Signal
3
2
  from PySide6.QtGui import QColor
4
3
  from PySide6.QtWidgets import (
@@ -11,6 +10,7 @@ from PySide6.QtWidgets import (
11
10
  QLabel,
12
11
  QColorDialog,
13
12
  QMessageBox,
13
+ QInputDialog,
14
14
  )
15
15
 
16
16
  from .db import DBManager
@@ -52,6 +52,10 @@ class TagBrowserDialog(QDialog):
52
52
  # Tag management buttons
53
53
  btn_row = QHBoxLayout()
54
54
 
55
+ self.add_tag_btn = QPushButton(strings._("add_a_tag"))
56
+ self.add_tag_btn.clicked.connect(self._add_a_tag)
57
+ btn_row.addWidget(self.add_tag_btn)
58
+
55
59
  self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
56
60
  self.edit_name_btn.clicked.connect(self._edit_tag_name)
57
61
  self.edit_name_btn.setEnabled(False)
@@ -155,6 +159,23 @@ class TagBrowserDialog(QDialog):
155
159
  self.openDateRequested.emit(date_iso)
156
160
  self.accept()
157
161
 
162
+ def _add_a_tag(self):
163
+ """Add a new tag"""
164
+
165
+ new_name, ok = QInputDialog.getText(
166
+ self, strings._("add_a_tag"), strings._("new_tag_name"), text=""
167
+ )
168
+
169
+ if ok and new_name:
170
+ color = QColorDialog.getColor(QColor(), self)
171
+ if color.isValid():
172
+ try:
173
+ self._db.add_tag(new_name, color.name())
174
+ self._populate(None)
175
+ self.tagsModified.emit()
176
+ except IntegrityError as e:
177
+ QMessageBox.critical(self, strings._("db_database_error"), str(e))
178
+
158
179
  def _edit_tag_name(self):
159
180
  """Edit the name of the selected tag"""
160
181
  item = self.tree.currentItem()
@@ -169,9 +190,6 @@ class TagBrowserDialog(QDialog):
169
190
  old_name = data["name"]
170
191
  color = data["color"]
171
192
 
172
- # Simple input dialog
173
- from PySide6.QtWidgets import QInputDialog
174
-
175
193
  new_name, ok = QInputDialog.getText(
176
194
  self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
177
195
  )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.3"
3
+ version = "0.3.1"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes