From 5bf6d4c4d608cc20e20cc39330072cefe6567c23 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 23 Nov 2025 18:34:02 +1100 Subject: [PATCH] Improve moving unchecked TODOs to next weekday and from last 7 days. New version checker. Remove newline after headings --- CHANGELOG.md | 8 + README.md | 8 +- bouquin/keys/mig5.asc | 109 +++++++++++ bouquin/locales/en.json | 16 +- bouquin/locales/fr.json | 2 +- bouquin/locales/it.json | 2 +- bouquin/main_window.py | 147 ++++++++++---- bouquin/markdown_editor.py | 14 +- bouquin/settings_dialog.py | 2 +- bouquin/version_check.py | 386 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- release.sh | 4 +- tests/test_main_window.py | 71 ++++++- 13 files changed, 701 insertions(+), 70 deletions(-) create mode 100644 bouquin/keys/mig5.asc create mode 100644 bouquin/version_check.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4078b8b..1f57337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora diff --git a/README.md b/README.md index 11baba5..d59f08a 100644 --- a/README.md +++ b/README.md @@ -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. 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 @@ -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) * Dark and light theme support * 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 - * Ability to set reminder alarms in the app against the current line of text on today's date - * Ability to log time per day and run timesheet reports + * 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 for different projects/activities and run timesheet reports ## How to install diff --git a/bouquin/keys/mig5.asc b/bouquin/keys/mig5.asc new file mode 100644 index 0000000..81d5fc7 --- /dev/null +++ b/bouquin/keys/mig5.asc @@ -0,0 +1,109 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=IfFs +-----END PGP PUBLIC KEY BLOCK----- diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index b482290..56fff73 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -57,6 +57,20 @@ "couldnt_open": "Couldn't open", "report_a_bug": "Report a bug", "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", "current": "current", "selected": "selected", @@ -82,7 +96,7 @@ "open_in_new_tab": "Open in new tab", "autosave": "autosave", "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", "images": "Images", "reopen_failed": "Re-open failed", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index d670436..7719b04 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -81,7 +81,7 @@ "open_in_new_tab": "Ouvrir dans un nouvel onglet", "autosave": "enregistrement automatique", "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 d’hier vers aujourd’hui", + "move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés au jour suivant", "insert_images": "Insérer des images", "images": "Images", "reopen_failed": "Échec de la réouverture", diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json index deedb5f..2bfa27a 100644 --- a/bouquin/locales/it.json +++ b/bouquin/locales/it.json @@ -81,7 +81,7 @@ "open_in_new_tab": "Apri in una nuova scheda", "autosave": "salvataggio automatico", "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", "images": "Immagini", "reopen_failed": "Riapertura fallita", diff --git a/bouquin/main_window.py b/bouquin/main_window.py index e2fc222..52d6f96 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1,7 +1,6 @@ from __future__ import annotations import datetime -import importlib.metadata import os import sys import re @@ -68,6 +67,7 @@ from .tags_widget import PageTagsWidget from .theme import ThemeManager from .time_log import TimeLogWidget from .toolbar import ToolBar +from .version_check import VersionChecker class MainWindow(QMainWindow): @@ -77,6 +77,7 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 650) self.themes = themes # Store the themes manager + self.version_checker = VersionChecker(self) self.cfg = load_db_config() if not os.path.exists(self.cfg.path): @@ -310,7 +311,7 @@ class MainWindow(QMainWindow): self._reminder_timers: list[QTimer] = [] # 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._refresh_calendar_marks() @@ -333,6 +334,12 @@ class MainWindow(QMainWindow): # Build any alarms for *today* from stored markdown 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 def editor(self) -> MarkdownEditor | None: """Get the currently active editor.""" @@ -783,46 +790,112 @@ class MainWindow(QMainWindow): today = QDate.currentDate() self._create_new_tab(today) - def _load_yesterday_todos(self): - if not self.cfg.move_todos: - return - yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") - text = self.db.get_entry(yesterday_str) - unchecked_items = [] + def _rollover_target_date(self, day: QDate) -> QDate: + """ + Given a 'new day' (system date), return the date we should move + unfinished todos *to*. - # Split into lines and find unchecked checkbox items - lines = text.split("\n") - remaining_lines = [] + 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 - for line in lines: - # Check for unchecked markdown checkboxes: - [ ] or - [☐] - if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( - r"^\s*-\s*\[☐\]\s+", line - ): - # Extract the text after the checkbox - item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) - unchecked_items.append(f"- [ ] {item_text}") - else: - # Keep all other lines - remaining_lines.append(line) + 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) - # Save modified content back if we moved items - if unchecked_items: - modified_text = "\n".join(remaining_lines) - self.db.save_new_version( - yesterday_str, - modified_text, - strings._("unchecked_checkbox_items_moved_to_next_day"), - ) + @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) - # Join unchecked items into markdown format - unchecked_str = "\n".join(unchecked_items) + "\n" + # Same logic as on startup + if not self._load_unchecked_todos(): + self._load_selected_date() - # Load the unchecked items into the current editor - self._load_selected_date(False, unchecked_str) - else: + 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 + + lines = text.split("\n") + remaining_lines: list[str] = [] + moved_from_this_day = False + + for line in lines: + # Unchecked markdown checkboxes: "- [ ] " or "- [☐] " + if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( + r"^\s*-\s*\[☐\]\s+", line + ): + item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line) + all_unchecked.append(f"- [ ] {item_text}") + moved_from_this_day = True + any_moved = True + else: + remaining_lines.append(line) + + if moved_from_this_day: + modified_text = "\n".join(remaining_lines) + # Save the cleaned-up source day + self.db.save_new_version( + src_iso, + modified_text, + strings._("unchecked_checkbox_items_moved_to_next_day"), + ) + + if not any_moved: + 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): """ When the calendar selection changes, save the previous day's note if dirty, @@ -1562,9 +1635,7 @@ class MainWindow(QMainWindow): dlg.exec() def _open_version(self): - version = importlib.metadata.version("bouquin") - version_formatted = f"{APP_NAME} {version}" - QMessageBox.information(self, strings._("version"), version_formatted) + self.version_checker.show_version_dialog() # ----------------- Idle handlers ----------------- # def _apply_idle_minutes(self, minutes: int): diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index ef1590e..6353034 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -551,6 +551,7 @@ class MarkdownEditor(QTextEdit): c.setPosition(new_pos) self.setTextCursor(c) return + # Step out of a code block with Down at EOF if event.key() == Qt.Key.Key_Down: c = self.textCursor() @@ -758,19 +759,6 @@ class MarkdownEditor(QTextEdit): super().keyPressEvent(event) 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 list_type, prefix = self._detect_list_type(current_line) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index e749c4c..8341e8b 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -160,7 +160,7 @@ class SettingsDialog(QDialog): features_layout = QVBoxLayout(features_group) 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.setCursor(Qt.PointingHandCursor) diff --git a/bouquin/version_check.py b/bouquin/version_check.py new file mode 100644 index 0000000..8f62589 --- /dev/null +++ b/bouquin/version_check.py @@ -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, + ) diff --git a/pyproject.toml b/pyproject.toml index 19850c4..e067891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" license = "GPL-3.0-or-later" repository = "https://git.mig5.net/mig5/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] python = ">=3.10,<3.14" diff --git a/release.sh b/release.sh index 04661eb..a7e9c28 100755 --- a/release.sh +++ b/release.sh @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -eo pipefail rm -rf dist @@ -15,3 +15,5 @@ mv Bouquin.AppImage dist/ # Sign packages 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." diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 58f7753..0062961 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -15,6 +15,8 @@ from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent 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): s = get_settings() @@ -73,7 +75,7 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): qtbot.addWidget(w) w.show() - w._load_yesterday_todos() + w._load_unchecked_todos() assert "carry me" in w.editor.to_markdown() 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) qtbot.addWidget(w) - called = {"title": None, "text": None} + called = {"title": None, "text": None, "check_called": False} - def fake_information(parent, title, text, *a, **k): - called["title"] = title - called["text"] = text - # Return value of QMessageBox.information is an int; 0 is fine. - return 0 + # 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 - # Patch whichever one you actually use in _open_version - monkeypatch.setattr(QMessageBox, "information", fake_information) + 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 + + def setText(self, text): + self._text = text + called["text"] = text + + def addButton(self, *args, **kwargs): + # 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() + # Assertions: title and text got set correctly assert called["title"] is not None assert "version" in called["title"].lower() + version = importlib.metadata.version("bouquin") 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) ----