Improve moving unchecked TODOs to next weekday and from last 7 days. New version checker. Remove newline after headings
This commit is contained in:
parent
ab0a9400c9
commit
5bf6d4c4d6
13 changed files with 701 additions and 70 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
109
bouquin/keys/mig5.asc
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
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
|
||||||
|
=IfFs
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 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",
|
"insert_images": "Insérer des images",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"reopen_failed": "Échec de la réouverture",
|
"reopen_failed": "Échec de la réouverture",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
386
bouquin/version_check.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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) ----
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue