Improve moving unchecked TODOs to next weekday and from last 7 days. New version checker. Remove newline after headings
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2025-11-23 18:34:02 +11:00
parent ab0a9400c9
commit 5bf6d4c4d6
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
13 changed files with 701 additions and 70 deletions

View file

@ -1,3 +1,11 @@
# 0.4.4
* Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday
* Moving unchecked TODOs now skips placing them on weekends.
* Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup)
* Check for new version / download new AppImage via the Help -> Version screen.
* Remove extra newline after headings
# 0.4.3 # 0.4.3
* Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora

View file

@ -19,7 +19,7 @@ To increase security, the SQLCipher key is requested when the app is opened, and
to disk unless the user configures it to be in the settings. to disk unless the user configures it to be in the settings.
There is deliberately no network connectivity or syncing intended, other than the option to send a bug There is deliberately no network connectivity or syncing intended, other than the option to send a bug
report from within the app. report from within the app, or optionally to check for new versions to upgrade to.
## Screenshots ## Screenshots
@ -64,10 +64,10 @@ report from within the app.
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
* Dark and light theme support * Dark and light theme support
* Automatically generate checkboxes when typing 'TODO' * Automatically generate checkboxes when typing 'TODO'
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup * It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
* English, French and Italian locales provided * English, French and Italian locales provided
* Ability to set reminder alarms in the app against the current line of text on today's date * Ability to set reminder alarms in the app against the current line of text (which will be flashed as the reminder)
* Ability to log time per day and run timesheet reports * Ability to log time per day for different projects/activities and run timesheet reports
## How to install ## How to install

109
bouquin/keys/mig5.asc Normal file
View file

@ -0,0 +1,109 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=IfFs
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -57,6 +57,20 @@
"couldnt_open": "Couldn't open", "couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug", "report_a_bug": "Report a bug",
"version": "Version", "version": "Version",
"update": "Update",
"check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?",
"downloading": "Downloading",
"download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_key_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate", "navigate": "Navigate",
"current": "current", "current": "current",
"selected": "selected", "selected": "selected",
@ -82,7 +96,7 @@
"open_in_new_tab": "Open in new tab", "open_in_new_tab": "Open in new tab",
"autosave": "autosave", "autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_yesterdays_unchecked_todos_to_today_on_startup": "Move yesterday's unchecked TODOs to today on startup", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs from the last 7 days to next weekday",
"insert_images": "Insert images", "insert_images": "Insert images",
"images": "Images", "images": "Images",
"reopen_failed": "Re-open failed", "reopen_failed": "Re-open failed",

View file

@ -81,7 +81,7 @@
"open_in_new_tab": "Ouvrir dans un nouvel onglet", "open_in_new_tab": "Ouvrir dans un nouvel onglet",
"autosave": "enregistrement automatique", "autosave": "enregistrement automatique",
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant", "unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
"move_yesterdays_unchecked_todos_to_today_on_startup": "Au démarrage, déplacer les TODO non cochés dhier vers aujourdhui", "move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés au jour suivant",
"insert_images": "Insérer des images", "insert_images": "Insérer des images",
"images": "Images", "images": "Images",
"reopen_failed": "Échec de la réouverture", "reopen_failed": "Échec de la réouverture",

View file

@ -81,7 +81,7 @@
"open_in_new_tab": "Apri in una nuova scheda", "open_in_new_tab": "Apri in una nuova scheda",
"autosave": "salvataggio automatico", "autosave": "salvataggio automatico",
"unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo", "unchecked_checkbox_items_moved_to_next_day": "Le caselle non spuntate sono state spostate al giorno successivo",
"move_yesterdays_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati di ieri a oggi all'avvio", "move_unchecked_todos_to_today_on_startup": "Sposta i TODO non completati a oggi all'avvio",
"insert_images": "Inserisci immagini", "insert_images": "Inserisci immagini",
"images": "Immagini", "images": "Immagini",
"reopen_failed": "Riapertura fallita", "reopen_failed": "Riapertura fallita",

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import importlib.metadata
import os import os
import sys import sys
import re import re
@ -68,6 +67,7 @@ from .tags_widget import PageTagsWidget
from .theme import ThemeManager from .theme import ThemeManager
from .time_log import TimeLogWidget from .time_log import TimeLogWidget
from .toolbar import ToolBar from .toolbar import ToolBar
from .version_check import VersionChecker
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@ -77,6 +77,7 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650) self.setMinimumSize(1000, 650)
self.themes = themes # Store the themes manager self.themes = themes # Store the themes manager
self.version_checker = VersionChecker(self)
self.cfg = load_db_config() self.cfg = load_db_config()
if not os.path.exists(self.cfg.path): if not os.path.exists(self.cfg.path):
@ -310,7 +311,7 @@ class MainWindow(QMainWindow):
self._reminder_timers: list[QTimer] = [] self._reminder_timers: list[QTimer] = []
# First load + mark dates in calendar with content # First load + mark dates in calendar with content
if not self._load_yesterday_todos(): if not self._load_unchecked_todos():
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
@ -333,6 +334,12 @@ class MainWindow(QMainWindow):
# Build any alarms for *today* from stored markdown # Build any alarms for *today* from stored markdown
self._rebuild_reminders_for_today() self._rebuild_reminders_for_today()
# Rollover unchecked todos automatically when the calendar day changes
self._day_change_timer = QTimer(self)
self._day_change_timer.setSingleShot(True)
self._day_change_timer.timeout.connect(self._on_day_changed)
self._schedule_next_day_change()
@property @property
def editor(self) -> MarkdownEditor | None: def editor(self) -> MarkdownEditor | None:
"""Get the currently active editor.""" """Get the currently active editor."""
@ -783,46 +790,112 @@ class MainWindow(QMainWindow):
today = QDate.currentDate() today = QDate.currentDate()
self._create_new_tab(today) self._create_new_tab(today)
def _load_yesterday_todos(self): def _rollover_target_date(self, day: QDate) -> QDate:
if not self.cfg.move_todos: """
return Given a 'new day' (system date), return the date we should move
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") unfinished todos *to*.
text = self.db.get_entry(yesterday_str)
unchecked_items = [] If the new day is Saturday or Sunday, we skip ahead to the next Monday.
Otherwise we just return the same day.
"""
# Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7)
return day.addDays(8 - dow) # 6 -> +2, 7 -> +1 (next Monday)
return day
def _schedule_next_day_change(self) -> None:
"""
Schedule a one-shot timer to fire shortly after the next midnight.
"""
now = QDateTime.currentDateTime()
tomorrow = now.date().addDays(1)
# A couple of minutes after midnight to be safe
next_run = QDateTime(tomorrow, QTime(0, 2))
msecs = max(60_000, now.msecsTo(next_run)) # at least 1 minute
self._day_change_timer.start(msecs)
@Slot()
def _on_day_changed(self) -> None:
"""
Called when we've crossed into a new calendar day (according to the timer).
Re-runs the rollover logic and refreshes the UI.
"""
# Make the calendar show the *real* new day first
today = QDate.currentDate()
with QSignalBlocker(self.calendar):
self.calendar.setSelectedDate(today)
# Same logic as on startup
if not self._load_unchecked_todos():
self._load_selected_date()
self._refresh_calendar_marks()
self._rebuild_reminders_for_today()
self._schedule_next_day_change()
def _load_unchecked_todos(self, days_back: int = 7) -> bool:
"""
Move unchecked checkbox items from the last `days_back` days
into the rollover target date (today, or next Monday if today
is a weekend).
Returns True if any items were moved, False otherwise.
"""
if not getattr(self.cfg, "move_todos", False):
return False
if not getattr(self, "db", None):
return False
today = QDate.currentDate()
target_date = self._rollover_target_date(today)
target_iso = target_date.toString("yyyy-MM-dd")
all_unchecked: list[str] = []
any_moved = False
# Look back N days (yesterday = 1, up to `days_back`)
for delta in range(1, days_back + 1):
src_date = today.addDays(-delta)
src_iso = src_date.toString("yyyy-MM-dd")
text = self.db.get_entry(src_iso)
if not text:
continue
# Split into lines and find unchecked checkbox items
lines = text.split("\n") lines = text.split("\n")
remaining_lines = [] remaining_lines: list[str] = []
moved_from_this_day = False
for line in lines: for line in lines:
# Check for unchecked markdown checkboxes: - [ ] or - [☐] # Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
r"^\s*-\s*\[☐\]\s+", line r"^\s*-\s*\[☐\]\s+", line
): ):
# Extract the text after the checkbox
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
unchecked_items.append(f"- [ ] {item_text}") all_unchecked.append(f"- [ ] {item_text}")
moved_from_this_day = True
any_moved = True
else: else:
# Keep all other lines
remaining_lines.append(line) remaining_lines.append(line)
# Save modified content back if we moved items if moved_from_this_day:
if unchecked_items:
modified_text = "\n".join(remaining_lines) modified_text = "\n".join(remaining_lines)
# Save the cleaned-up source day
self.db.save_new_version( self.db.save_new_version(
yesterday_str, src_iso,
modified_text, modified_text,
strings._("unchecked_checkbox_items_moved_to_next_day"), strings._("unchecked_checkbox_items_moved_to_next_day"),
) )
# Join unchecked items into markdown format if not any_moved:
unchecked_str = "\n".join(unchecked_items) + "\n"
# Load the unchecked items into the current editor
self._load_selected_date(False, unchecked_str)
else:
return False return False
# Append everything we collected to the *target* date
unchecked_str = "\n".join(all_unchecked) + "\n"
self._load_selected_date(target_iso, unchecked_str)
return True
def _on_date_changed(self): def _on_date_changed(self):
""" """
When the calendar selection changes, save the previous day's note if dirty, When the calendar selection changes, save the previous day's note if dirty,
@ -1562,9 +1635,7 @@ class MainWindow(QMainWindow):
dlg.exec() dlg.exec()
def _open_version(self): def _open_version(self):
version = importlib.metadata.version("bouquin") self.version_checker.show_version_dialog()
version_formatted = f"{APP_NAME} {version}"
QMessageBox.information(self, strings._("version"), version_formatted)
# ----------------- Idle handlers ----------------- # # ----------------- Idle handlers ----------------- #
def _apply_idle_minutes(self, minutes: int): def _apply_idle_minutes(self, minutes: int):

View file

@ -551,6 +551,7 @@ class MarkdownEditor(QTextEdit):
c.setPosition(new_pos) c.setPosition(new_pos)
self.setTextCursor(c) self.setTextCursor(c)
return return
# Step out of a code block with Down at EOF # Step out of a code block with Down at EOF
if event.key() == Qt.Key.Key_Down: if event.key() == Qt.Key.Key_Down:
c = self.textCursor() c = self.textCursor()
@ -758,19 +759,6 @@ class MarkdownEditor(QTextEdit):
super().keyPressEvent(event) super().keyPressEvent(event)
return return
# Auto-insert an extra blank line after headings (#, ##, ###)
# when pressing Enter at the end of the line.
if re.match(r"^#{1,3}\s+", stripped) and pos_in_block >= len(line_text):
cursor.beginEditBlock()
# First blank line: visual separator between heading and body
cursor.insertBlock()
# Second blank line: where body text will start (caret ends here)
cursor.insertBlock()
cursor.endEditBlock()
self.setTextCursor(cursor)
return
# Check for list continuation # Check for list continuation
list_type, prefix = self._detect_list_type(current_line) list_type, prefix = self._detect_list_type(current_line)

View file

@ -160,7 +160,7 @@ class SettingsDialog(QDialog):
features_layout = QVBoxLayout(features_group) features_layout = QVBoxLayout(features_group)
self.move_todos = QCheckBox( self.move_todos = QCheckBox(
strings._("move_yesterdays_unchecked_todos_to_today_on_startup") strings._("move_unchecked_todos_to_today_on_startup")
) )
self.move_todos.setChecked(self.current_settings.move_todos) self.move_todos.setChecked(self.current_settings.move_todos)
self.move_todos.setCursor(Qt.PointingHandCursor) self.move_todos.setCursor(Qt.PointingHandCursor)

386
bouquin/version_check.py Normal file
View file

@ -0,0 +1,386 @@
from __future__ import annotations
import importlib.metadata
import os
import re
import subprocess # nosec
import tempfile
from pathlib import Path
import requests
from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QWidget,
QProgressDialog,
)
from .settings import APP_NAME
from . import strings
# Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt"
# Name of the installed distribution according to pyproject.toml
# (used with importlib.metadata.version)
DIST_NAME = "bouquin"
# Base URL where AppImages are hosted
APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download"
# Where we expect to find the bundled public key, relative to the *installed* package.
GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc")
class VersionChecker:
"""
Handles:
* showing the version dialog
* checking for updates
* downloading & verifying a new AppImage
All dialogs use `parent` as their parent widget.
"""
def __init__(self, parent: QWidget | None = None):
self._parent = parent
# ---------- Version helpers ---------- #
def current_version(self) -> str:
"""
Return the current app version as reported by importlib.metadata
"""
try:
return importlib.metadata.version(DIST_NAME)
except importlib.metadata.PackageNotFoundError:
# Fallback for editable installs / dev trees
return "0.0.0"
@staticmethod
def _parse_version(v: str) -> tuple[int, ...]:
"""
Very small helper to compare simple semantic versions like 1.2.3.
Extracts numeric components and returns them as a tuple.
"""
parts = re.findall(r"\d+", v)
if not parts:
return (0,)
return tuple(int(p) for p in parts)
def _is_newer_version(self, available: str, current: str) -> bool:
"""
True if `available` > `current` according to _parse_version.
"""
return self._parse_version(available) > self._parse_version(current)
# ---------- Public entrypoint for Help → Version ---------- #
def show_version_dialog(self) -> None:
"""
Show the Version dialog with a 'Check for updates' button.
"""
version = self.current_version()
version_formatted = f"{APP_NAME} {version}"
box = QMessageBox(self._parent)
box.setIcon(QMessageBox.Information)
box.setWindowTitle(strings._("version"))
box.setText(version_formatted)
check_button = box.addButton(
strings._("check_for_updates"), QMessageBox.ActionRole
)
box.addButton(QMessageBox.Close)
box.exec()
if box.clickedButton() is check_button:
self.check_for_updates()
# ---------- Core update logic ---------- #
def check_for_updates(self) -> None:
"""
Fetch VERSION_URL, compare against the current version, and optionally
download + verify a new AppImage.
"""
current = self.current_version()
try:
resp = requests.get(VERSION_URL, timeout=10)
resp.raise_for_status()
available_raw = resp.text.strip()
except Exception as e:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("could_not_check_for_updates") + e,
)
return
if not available_raw:
QMessageBox.warning(
self._parent,
strings._("update"),
strings._("update_server_returned_an_empty_version_string"),
)
return
if not self._is_newer_version(available_raw, current):
QMessageBox.information(
self._parent,
strings._("update"),
strings._("you_are_running_the_latest_version") + f"({current}).",
)
return
# Newer version is available
reply = QMessageBox.question(
self._parent,
strings._("update"),
(
strings._("there_is_a_new_version_available")
+ available_raw
+ "\n\n"
+ strings._("download_the_appimage")
),
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self._download_and_verify_appimage(available_raw)
# ---------- Download + verification helpers ---------- #
def _download_file(
self,
url: str,
dest_path: Path,
timeout: int = 30,
progress: QProgressDialog | None = None,
label: str | None = None,
) -> None:
"""
Stream a URL to a local file, optionally updating a QProgressDialog.
If the user cancels via the dialog, raises RuntimeError.
"""
resp = requests.get(url, timeout=timeout, stream=True)
resp.raise_for_status()
dest_path.parent.mkdir(parents=True, exist_ok=True)
total_bytes: int | None = None
content_length = resp.headers.get("Content-Length")
if content_length is not None:
try:
total_bytes = int(content_length)
except ValueError:
total_bytes = None
if progress is not None:
progress.setLabelText(
label or strings._("downloading") + f" {dest_path.name}..."
)
# Unknown size → busy indicator; known size → real range
if total_bytes is not None and total_bytes > 0:
progress.setRange(0, total_bytes)
else:
progress.setRange(0, 0) # indeterminate
progress.setValue(0)
progress.show()
QApplication.processEvents()
downloaded = 0
with dest_path.open("wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if not chunk:
continue
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
if total_bytes is not None and total_bytes > 0:
progress.setValue(downloaded)
else:
# Just bump a little so the dialog looks alive
progress.setValue(progress.value() + 1)
QApplication.processEvents()
if progress.wasCanceled():
raise RuntimeError(strings._("download_cancelled"))
if progress is not None and total_bytes is not None and total_bytes > 0:
progress.setValue(total_bytes)
QApplication.processEvents()
def _download_and_verify_appimage(self, version: str) -> None:
"""
Download the AppImage + its GPG signature to the user's Downloads dir,
then verify it with a bundled public key.
"""
# Where to put the file
download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
if not download_dir:
download_dir = os.path.expanduser("~/Downloads")
download_dir = Path(download_dir)
download_dir.mkdir(parents=True, exist_ok=True)
# Construct AppImage filename and URLs
appimage_path = download_dir / "Bouquin.AppImage"
sig_path = Path(str(appimage_path) + ".asc")
appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage"
sig_url = f"{appimage_url}.asc"
# Progress dialog covering both downloads
progress = QProgressDialog(
"Downloading update...",
"Cancel",
0,
100,
self._parent,
)
progress.setWindowTitle(strings._("update"))
progress.setWindowModality(Qt.WindowModal)
progress.setAutoClose(False)
progress.setAutoReset(False)
try:
# AppImage download
self._download_file(
appimage_url,
appimage_path,
progress=progress,
label=strings._("downloading") + " Bouquin.AppImage...",
)
# Signature download (usually tiny, but we still show it)
self._download_file(
sig_url,
sig_path,
progress=progress,
label=strings._("downloading") + " signature...",
)
except RuntimeError:
# User cancelled
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError:
pass
progress.close()
QMessageBox.information(
self._parent,
strings._("update"),
strings._("download_cancelled"),
)
return
except Exception as e:
# Other error
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError:
pass
progress.close()
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("failed_to_download_update") + e,
)
return
progress.close()
# Load the bundled public key
try:
pkg, *rel = GPG_PUBKEY_RESOURCE
pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes()
except Exception as e:
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_read_bundled_gpg_public_key") + e,
)
# On failure, delete the downloaded files for safety
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError:
pass
return
# Use a temporary GNUPGHOME so we don't touch the user's main keyring
try:
with tempfile.TemporaryDirectory() as gnupg_home:
pubkey_path = Path(gnupg_home) / "pubkey.asc"
pubkey_path.write_bytes(pubkey_bytes)
# Import the key
subprocess.run(
["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
# Verify the signature
subprocess.run(
[
"gpg",
"--homedir",
gnupg_home,
"--verify",
str(sig_path),
str(appimage_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
) # nosec
except FileNotFoundError:
# gpg not installed / not on PATH
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError:
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("could_not_find_gpg_executable"),
)
return
except subprocess.CalledProcessError as e:
for p in (appimage_path, sig_path):
try:
if p.exists():
p.unlink()
except OSError:
pass
QMessageBox.critical(
self._parent,
strings._("update"),
strings._("gpg_signature_verification_failed")
+ e.stderr.decode(errors="ignore"),
)
return
# Success
QMessageBox.information(
self._parent,
strings._("update"),
strings._("downloaded_and_verified_new_appimage") + appimage_path,
)

View file

@ -7,7 +7,7 @@ readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin" repository = "https://git.mig5.net/mig5/bouquin"
packages = [{ include = "bouquin" }] packages = [{ include = "bouquin" }]
include = ["bouquin/locales/*.json"] include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/NotoSansSymbols2-Regular.ttf", "bouquin/fonts/OFL.txt"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.10,<3.14" python = ">=3.10,<3.14"

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -eo pipefail
rm -rf dist rm -rf dist
@ -15,3 +15,5 @@ mv Bouquin.AppImage dist/
# Sign packages # Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
echo "Don't forget to update version string on remote server."

View file

@ -15,6 +15,8 @@ from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import bouquin.version_check as version_check
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
@ -73,7 +75,7 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
qtbot.addWidget(w) qtbot.addWidget(w)
w.show() w.show()
w._load_yesterday_todos() w._load_unchecked_todos()
assert "carry me" in w.editor.to_markdown() assert "carry me" in w.editor.to_markdown()
y_txt = fresh_db.get_entry(y) y_txt = fresh_db.get_entry(y)
@ -922,24 +924,75 @@ def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w) qtbot.addWidget(w)
called = {"title": None, "text": None} called = {"title": None, "text": None, "check_called": False}
def fake_information(parent, title, text, *a, **k): # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses
class FakeMessageBox:
# provide the enum attributes the code references
Information = 0
ActionRole = 1
Close = 2
def __init__(self, parent=None):
self._parent = parent
self._icon = None
self._title = ""
self._text = ""
self._buttons = []
self._clicked = None
def setIcon(self, icon):
self._icon = icon
def setWindowTitle(self, title):
self._title = title
called["title"] = title called["title"] = title
def setText(self, text):
self._text = text
called["text"] = text called["text"] = text
# Return value of QMessageBox.information is an int; 0 is fine.
return 0
# Patch whichever one you actually use in _open_version def addButton(self, *args, **kwargs):
monkeypatch.setattr(QMessageBox, "information", fake_information) # We don't care about the label/role, we just need a distinct object
btn = object()
self._buttons.append(btn)
return btn
def exec(self):
# Simulate user clicking the *Close* button, i.e. the second button
if self._buttons:
# show_version_dialog adds buttons in order:
# 0 -> "Check for updates"
# 1 -> Close
self._clicked = self._buttons[-1]
def clickedButton(self):
return self._clicked
# Patch the QMessageBox used *inside* version_check.py
monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox)
# Optional: track if check_for_updates would be called
def fake_check_for_updates(self):
called["check_called"] = True
monkeypatch.setattr(
version_check.VersionChecker, "check_for_updates", fake_check_for_updates
)
# Call the entrypoint
w._open_version() w._open_version()
# Assertions: title and text got set correctly
assert called["title"] is not None assert called["title"] is not None
assert "version" in called["title"].lower() assert "version" in called["title"].lower()
version = importlib.metadata.version("bouquin") version = importlib.metadata.version("bouquin")
assert version in called["text"] assert version in called["text"]
# And we simulated closing, so "Check for updates" should not have fired
assert called["check_called"] is False
# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ---- # ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ----