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.
- {bouquin-0.3 → bouquin-0.3.1}/PKG-INFO +2 -2
- {bouquin-0.3 → bouquin-0.3.1}/README.md +1 -1
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/db.py +146 -16
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/en.json +16 -1
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/fr.json +1 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/locales/it.json +1 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/main_window.py +18 -8
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/markdown_editor.py +60 -1
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/markdown_highlighter.py +19 -0
- bouquin-0.3.1/bouquin/statistics_dialog.py +294 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/tag_browser.py +22 -4
- {bouquin-0.3 → bouquin-0.3.1}/pyproject.toml +1 -1
- {bouquin-0.3 → bouquin-0.3.1}/LICENSE +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/__init__.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/__main__.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/find_bar.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/flow_layout.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/history_dialog.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/key_prompt.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/lock_overlay.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/main.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/save_dialog.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/search.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/settings.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/settings_dialog.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/strings.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/tags_widget.py +0 -0
- {bouquin-0.3 → bouquin-0.3.1}/bouquin/theme.py +0 -0
- {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,
|
|
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,
|
|
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",
|
|
@@ -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(
|
|
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, ".
|
|
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("
|
|
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
|
)
|
|
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
|
|
File without changes
|