From 2d1c4f5b217eca506c565c43402b21c2d3532060 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 22 Dec 2025 17:17:04 +1100 Subject: [PATCH 01/31] Add .desktop file for Debian --- CHANGELOG.md | 4 ++++ debian/bouquin.desktop | 13 +++++++++++++ debian/bouquin.install | 2 ++ tests-debian-packaging.sh | 25 +++++++++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 debian/bouquin.desktop create mode 100644 debian/bouquin.install create mode 100755 tests-debian-packaging.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e236649..0027e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.7.6 + + * Add .desktop file for Debian + # 0.7.5 * Fix import of sqlcipher4 diff --git a/debian/bouquin.desktop b/debian/bouquin.desktop new file mode 100644 index 0000000..ba622e5 --- /dev/null +++ b/debian/bouquin.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=Bouquin +GenericName=Journal +Comment=Daily planner with calendar, keyword searching, version control, time logging and document management +Exec=bouquin +Icon=bouquin +Terminal=false +Type=Application +Categories=Office;Calendar; +Keywords=Journal;Diary;Notes;Notebook; +StartupNotify=true +X-GNOME-Gettext-Domain=bouquin diff --git a/debian/bouquin.install b/debian/bouquin.install new file mode 100644 index 0000000..33cffb7 --- /dev/null +++ b/debian/bouquin.install @@ -0,0 +1,2 @@ +debian/bouquin.desktop usr/share/applications/ +bouquin/icons/bouquin.svg usr/share/icons/hicolor/scalable/apps/ diff --git a/tests-debian-packaging.sh b/tests-debian-packaging.sh new file mode 100755 index 0000000..cbefc31 --- /dev/null +++ b/tests-debian-packaging.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -eou pipefail + +DISTS=( + debian:trixie +) + +for dist in ${DISTS[@]}; do + release=$(echo ${dist} | cut -d: -f2) + mkdir -p dist/${release} + + docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \ + --no-cache \ + --progress=plain \ + --build-arg BASE_IMAGE=${dist} . + + docker run --rm \ + -e SUITE="${release}" \ + -v "$PWD":/src \ + -v "$PWD/dist/${release}":/out \ + bouquin-deb:${release} + + debfile=$(ls -1 dist/${release}/*.deb) +done From d0c6c94e9d9c77c7e9b3e108f54dccf48bb4e7d0 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 22 Dec 2025 17:27:42 +1100 Subject: [PATCH 02/31] Fix trivy exit code --- .forgejo/workflows/trivy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml index fad2f6f..d5585f4 100644 --- a/.forgejo/workflows/trivy.yml +++ b/.forgejo/workflows/trivy.yml @@ -23,7 +23,7 @@ jobs: - name: Run trivy run: | - trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . + trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 . # Notify if any previous step in this job failed - name: Notify on failure From b925d2e89eae9d22eabe5d9aa1bd2f27effed101 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 13:19:26 +1100 Subject: [PATCH 03/31] Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. --- CHANGELOG.md | 1 + bouquin/pomodoro_timer.py | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0027e37..260aeca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 0.7.6 * Add .desktop file for Debian + * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. # 0.7.5 diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index e66c1f4..bde75fb 100644 --- a/bouquin/pomodoro_timer.py +++ b/bouquin/pomodoro_timer.py @@ -111,6 +111,25 @@ class PomodoroManager: self._parent = parent_window self._active_timer: Optional[PomodoroTimer] = None + @staticmethod + def _seconds_to_logged_hours(elapsed_seconds: int) -> float: + """Convert elapsed seconds to decimal hours for logging. + + Rules: + - For very short runs (< 15 minutes), always round up to 0.25h (15 minutes). + - Otherwise, round to the closest 0.25h (15-minute) increment. + Halfway cases (e.g., 22.5 minutes) round up. + """ + if elapsed_seconds < 0: + elapsed_seconds = 0 + + # 15 minutes = 900 seconds + if elapsed_seconds < 900: + return 0.25 + + quarters = int(math.floor((elapsed_seconds / 900.0) + 0.5)) + return quarters * 0.25 + def start_timer_for_line(self, line_text: str, date_iso: str): """ Start a new timer for the given line of text and embed it into the @@ -156,9 +175,8 @@ class PomodoroManager: def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): """Handle timer stop - open time log dialog with pre-filled data.""" - # Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes) - quarter_hours = math.ceil(elapsed_seconds / 900) - hours = quarter_hours * 0.25 + # Convert seconds to decimal hours, and handle rounding up or down + hours = self._seconds_to_logged_hours(elapsed_seconds) # Ensure minimum of 0.25 hours if hours < 0.25: From 0a64dc525db6a7829f3f5048442068e8f4bb2d65 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 13:42:33 +1100 Subject: [PATCH 04/31] Allow setting a code block on a line that already has text (it will start a newline for the codeblock) --- CHANGELOG.md | 1 + bouquin/markdown_editor.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 260aeca..e25d1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add .desktop file for Debian * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. + * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) # 0.7.5 diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 3d30889..e77dded 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -919,8 +919,10 @@ class MarkdownEditor(QTextEdit): before = line[:pos_in_block] # "before" currently contains whatever's before the *third* backtick. - # We trigger only when the line is (whitespace + "``") before the caret. - if before.endswith("``") and before.strip() == "``": + # Trigger when the user types a *third consecutive* backtick anywhere on the line. + # (We require the run immediately before the caret to be exactly two backticks, + # so we don't trigger on 4+ backticks.) + if before.endswith("``") and (len(before) < 3 or before[-3] != "`"): doc = self.document() if doc is not None: # Remove the two backticks that were already typed From 4ff4d24b42ef981bb822d1864b87dc9d9d105f3f Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 14:04:43 +1100 Subject: [PATCH 05/31] Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation --- CHANGELOG.md | 1 + bouquin/markdown_editor.py | 42 +++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e25d1b1..eaf83a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Add .desktop file for Debian * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) + * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation # 0.7.5 diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index e77dded..78af734 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -89,6 +89,12 @@ class MarkdownEditor(QTextEdit): # Track current list type for smart enter handling self._last_enter_was_empty = False + # Track "double-enter" behavior for indentation retention. + # If we auto-insert indentation on a new line, the next Enter on that + # now-empty indented line should remove the indentation and return to + # column 0 (similar to how lists exit on a second Enter). + self._last_enter_was_empty_indent = False + # Track if we're currently updating text programmatically self._updating = False @@ -1128,6 +1134,10 @@ class MarkdownEditor(QTextEdit): cursor = self.textCursor() current_line = self._get_current_line() + # Leading indentation (tabs/spaces) on the current line. + m_indent = re.match(r"^([ \t]*)", current_line) + line_indent = m_indent.group(1) if m_indent else "" + # Check if we're in a code block current_block = cursor.block() line_text = current_block.text() @@ -1217,13 +1227,43 @@ class MarkdownEditor(QTextEdit): # Insert newline and continue the list super().keyPressEvent(event) cursor = self.textCursor() - cursor.insertText(prefix) + # Preserve any leading indentation so nested lists keep their level. + cursor.insertText(line_indent + prefix) + self._last_enter_was_empty_indent = False return else: + # Not a list: support indentation retention. If a line starts + # with indentation (tabs/spaces), carry that indentation to the + # next line. A *second* Enter on an empty indented line resets + # back to column 0. + if line_indent: + rest = current_line[len(line_indent) :] + indent_only = rest.strip() == "" + + if indent_only and self._last_enter_was_empty_indent: + # Second Enter on an empty indented line: remove the + # indentation-only line and start a fresh, unindented line. + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.insertText("\n") + self._last_enter_was_empty_indent = False + self._last_enter_was_empty = False + return + + # First Enter (or a non-empty indented line): keep the indent. + super().keyPressEvent(event) + cursor = self.textCursor() + cursor.insertText(line_indent) + self._last_enter_was_empty_indent = True + self._last_enter_was_empty = False + return + self._last_enter_was_empty = False + self._last_enter_was_empty_indent = False else: # Any other key resets the empty enter flag self._last_enter_was_empty = False + self._last_enter_was_empty_indent = False # Default handling super().keyPressEvent(event) From 426142c0c3e72cc4b9548c98b355745e269599df Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 15:21:00 +1100 Subject: [PATCH 06/31] Add missing strings (for English and French) --- CHANGELOG.md | 1 + bouquin/locales/en.json | 11 +- bouquin/locales/fr.json | 717 ++++++++++++++++++++++--------------- bouquin/settings_dialog.py | 2 +- 4 files changed, 438 insertions(+), 293 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf83a1..68ea976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation + * Add missing strings (for English and French) # 0.7.5 diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 6c13e42..8c3fa54 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -363,21 +363,19 @@ "documents_col_file": "File", "documents_col_description": "Description", "documents_col_added": "Added", - "documents_col_path": "Path", "documents_col_tags": "Tags", "documents_col_size": "Size", "documents_add": "&Add", - "documents_add_document": "Add a document", "documents_open": "&Open", "documents_delete": "&Delete", "documents_no_project_selected": "Please choose a project first.", "documents_file_filter_all": "All files (*)", "documents_add_failed": "Could not add document: {error}", "documents_open_failed": "Could not open document: {error}", - "documents_missing_file": "The file does not exist:\n{path}", "documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)", "documents_search_label": "Search", "documents_search_placeholder": "Type to search documents (all projects)", + "documents_invalid_date_format": "Invalid date format", "todays_documents": "Documents from this day", "todays_documents_none": "No documents yet.", "manage_invoices": "Manage Invoices", @@ -428,5 +426,10 @@ "invoice_company_logo_choose": "Choose logo", "invoice_company_logo_set": "Logo has been set", "invoice_company_logo_not_set": "Logo not set", - "invoice_number_unique": "Invoice number must be unique. This invoice number already exists." + "invoice_number_unique": "Invoice number must be unique. This invoice number already exists.", + "invoice_invalid_amount": "The amount is invalid", + "invoice_invalid_date_format": "Invalid date format", + "invoice_invalid_tax_rate": "The tax rate is invalid", + "invoice_no_items": "There are no items in the invoice", + "invoice_number_required": "An invoice number is required" } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index f77ebb1..4d304df 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -1,290 +1,431 @@ { - "db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher", - "db_issues_reported": "problème(s) signalé(s)", - "db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé", - "db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée", - "db_key_incorrect": "La clé est probablement incorrecte", - "db_database_error": "Erreur de base de données", - "database_maintenance": "Maintenance de la base de données", - "database_compact": "Compacter la base de données", - "database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.", - "database_compacted_successfully": "Base de données compactée avec succès !", - "encryption": "Chiffrement", - "remember_key": "Se souvenir de la clé", - "change_encryption_key": "Changer la clé de chiffrement", - "enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement", - "reenter_the_new_key": "Saisir de nouveau la nouvelle clé", - "key_mismatch": "Les clés ne correspondent pas", - "key_mismatch_explanation": "Les deux saisies ne correspondent pas.", - "empty_key": "La clé est vide", - "empty_key_explanation": "La clé ne peut pas être vide.", - "key_changed": "La clé a été modifiée", - "key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !", - "error": "Erreur", - "success": "Succès", - "close": "Fermer", - "find": "Rechercher", - "file": "Fichier", - "locale": "Langue", - "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.", - "settings": "Paramètres", - "theme": "Thème", - "system": "Système", - "light": "Clair", - "dark": "Sombre", - "never": "Jamais", - "close_tab": "Fermer l'onglet", - "previous": "Précédent", - "previous_day": "Jour précédent", - "next": "Suivant", - "next_day": "Jour suivant", - "today": "Aujourd'hui", - "show": "Afficher", - "history": "Historique", - "export_accessible_flag": "E&xporter", - "export_entries": "Exporter les entrées", - "export_complete": "Exportation terminée", - "export_failed": "Échec de l'exportation", - "backup": "Sauvegarder", - "backup_complete": "Sauvegarde terminée", - "backup_failed": "Échec de la sauvegarde", - "quit": "Quitter", - "cancel": "Annuler", - "save": "Enregistrer", - "help": "Aide", - "saved": "Enregistré", - "saved_to": "Enregistré dans", - "documentation": "Documentation", - "couldnt_open": "Impossible d'ouvrir", - "report_a_bug": "Signaler un bug", - "version": "Version", - "update": "Mise à jour", - "check_for_updates": "Rechercher des mises à jour", - "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n", - "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide", - "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n", - "there_is_a_new_version_available": "Une nouvelle version est disponible:\n", - "download_the_appimage": "Télécharger l'AppImage ?", - "downloading": "Téléchargement en cours", - "download_cancelled": "Téléchargement annulé", - "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n", - "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n", - "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.", - "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n", - "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n", - "navigate": "Naviguer", - "current": "actuel", - "selected": "sélectionné", - "find_on_page": "Rechercher dans la page", - "find_next": "Rechercher le suivant", - "find_previous": "Rechercher le précédent", - "find_bar_type_to_search": "Tapez pour rechercher", - "find_bar_match_case": "Respecter la casse", - "history_dialog_preview": "Aperçu", - "history_dialog_diff": "Différences", - "history_dialog_revert_to_selected": "Revenir à la sélection", - "history_dialog_revert_failed": "Échec de la restauration", - "history_dialog_delete": "Supprimer la révision", - "history_dialog_delete_failed": "Impossible de supprimer la révision", - "key_prompt_enter_key": "Saisir la clé", - "lock_overlay_locked": "Verrouillé", - "lock_overlay_unlock": "Déverrouiller", - "main_window_lock_screen_accessibility": "&Verrouiller l'écran", - "main_window_ready": "Prêt", - "main_window_save_a_version": "Enregistrer une version", - "main_window_settings_accessible_flag": "&Paramètres", - "set_an_encryption_key": "Définir une clé de chiffrement", - "set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !", - "unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré", - "unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin", - "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_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable", - "insert_images": "Insérer des images", - "images": "Images", - "reopen_failed": "Échec de la réouverture", - "unlock_failed": "Échec du déverrouillage", - "could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.", - "unencrypted_export": "Export non chiffré", - "unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.", - "unrecognised_extension": "Extension non reconnue !", - "backup_encrypted_notebook": "Sauvegarder le bouquin chiffré", - "enter_a_name_for_this_version": "Saisir un nom pour cette version", - "new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à", - "appearance": "Apparence", - "security": "Sécurité", - "features": "Fonctionnalités", - "database": "Base de données", - "save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.", - "lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité", - "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.", - "font_size": "Taille de police", - "font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.", - "search_for_notes_here": "Recherchez des notes ici", - "toolbar_format": "Format", - "toolbar_bold": "Gras", - "toolbar_italic": "Italique", - "toolbar_strikethrough": "Barré", - "toolbar_normal_paragraph_text": "Texte de paragraphe normal", - "toolbar_font_smaller": "Texte plus petit", - "toolbar_font_larger": "Texte plus grand", - "toolbar_bulleted_list": "Liste à puces", - "toolbar_numbered_list": "Liste numérotée", - "toolbar_code_block": "Bloc de code", - "toolbar_heading": "Titre", - "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", - "tags": "Étiquettes", - "tag": "Étiquette", - "manage_tags": "Gérer les étiquettes", - "add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée", - "tag_browser_title": "Navigateur d'étiquettes", - "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", - "color_hex": "Couleur", - "date": "Date", - "add_a_tag": "Ajouter une étiquette", - "edit_tag_name": "Modifier le nom de l'étiquette", - "new_tag_name": "Nouveau nom de l'étiquette :", - "change_color": "Changer la couleur", - "delete_tag": "Supprimer l'étiquette", - "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", - "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", - "statistics": "Statistiques", - "main_window_statistics_accessible_flag": "Stat&istiques", - "stats_pages_with_content": "Pages avec contenu (version actuelle)", - "stats_total_revisions": "Nombre total de révisions", - "stats_page_most_revisions": "Page avec le plus de révisions", - "stats_total_words": "Nombre total de mots (versions actuelles)", - "stats_unique_tags": "Étiquettes uniques", - "stats_page_most_tags": "Page avec le plus d'étiquettes", - "stats_activity_heatmap": "Carte de chaleur d'activité", - "stats_heatmap_metric": "Colorer selon", - "stats_metric_words": "Mots", - "stats_metric_revisions": "Révisions", - "stats_no_data": "Aucune statistique disponible pour le moment.", - "select_notebook": "Sélectionner un bouquin", - "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.", - "bug_report_placeholder": "Saisissez votre rapport de bug ici", - "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.", - "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.", - "bug_report_sent_ok": "Rapport de bug envoyé. Merci !", - "send": "Envoyer", - "reminder": "Rappel", - "set_reminder": "Définir le rappel", - "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !", - "invalid_time_title": "Heure invalide", - "invalid_time_message": "Veuillez saisir une heure au format HH:MM", - "dismiss": "Ignorer", - "toolbar_alarm": "Régler l'alarme de rappel", - "activities": "Activités", - "activity": "Activité", - "note": "Note", - "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité", - "activity_delete_error_title": "Problème lors de la suppression de l'activité", - "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité", - "activity_rename_error_title": "Problème lors du renommage de l'activité", - "activity_required_message": "Un nom d'activité est requis", - "activity_required_title": "Nom d'activité requis", - "add_activity": "Ajouter une activité", - "add_project": "Ajouter un projet", - "add_time_entry": "Ajouter une entrée de temps", - "time_period": "Période", - "by_day": "par jour", - "by_month": "par mois", - "by_week": "par semaine", - "date_range": "Plage de dates", - "delete_activity": "Supprimer l'activité", - "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?", - "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?", - "delete_project": "Supprimer le projet", - "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?", - "delete_project_title": "Supprimer le projet - êtes-vous sûr ?", - "delete_time_entry": "Supprimer l'entrée de temps", - "group_by": "Grouper par", - "hours": "Heures", - "invalid_activity_message": "L'activité est invalide", - "invalid_activity_title": "Activité invalide", - "invalid_project_message": "Le projet est invalide", - "invalid_project_title": "Projet invalide", - "manage_activities": "Gérer les activités", - "manage_projects": "Gérer les projets", - "manage_projects_activities": "Gérer les activités du projet", - "open_time_log": "Ouvrir le journal de temps", - "project": "Projet", - "project_delete_error_message": "Un problème est survenu lors de la suppression du projet", - "project_delete_error_title": "Problème lors de la suppression du projet", - "project_rename_error_message": "Un problème est survenu lors du renommage du projet", - "project_rename_error_title": "Problème lors du renommage du projet", - "project_required_message": "Un projet est requis", - "project_required_title": "Projet requis", - "projects": "Projets", - "rename_activity": "Renommer l'activité", - "rename_project": "Renommer le projet", - "run_report": "Exécuter le rapport", - "add_activity_title": "Ajouter une activité", - "add_activity_label": "Ajouter une activité", - "rename_activity_label": "Renommer l'activité", - "add_project_title": "Ajouter un projet", - "add_project_label": "Ajouter un projet", - "rename_activity_title": "Renommer cette activité", - "rename_project_label": "Renommer le projet", - "rename_project_title": "Renommer ce projet", - "select_activity_message": "Sélectionner une activité", - "select_activity_title": "Sélectionner une activité", - "select_project_message": "Sélectionner un projet", - "select_project_title": "Sélectionner un projet", - "time_log": "Journal de temps", - "time_log_collapsed_hint": "Journal de temps", - "time_log_date_label": "Date du journal de temps : {date}", - "time_log_for": "Journal de temps pour {date}", - "time_log_no_date": "Journal de temps", - "time_log_no_entries": "Aucune entrée de temps pour l'instant", - "time_log_report": "Rapport de temps", - "time_log_report_title": "Journal de temps pour {project}", - "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}", - "time_log_total_hours": "Total pour la journée : {hours:.2f}h", - "time_log_with_total": "Journal de temps ({hours:.2f}h)", - "update_time_entry": "Mettre à jour l'entrée de temps", - "time_report_total": "Total : {hours:.2f} heures", - "no_report_title": "Aucun rapport", - "no_report_message": "Veuillez exécuter un rapport avant d'exporter.", - "total": "Total", - "export_csv": "Exporter en CSV", - "export_csv_error_title": "Échec de l'exportation", - "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}", - "export_pdf": "Exporter en PDF", - "export_pdf_error_title": "Échec de l'exportation PDF", - "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}", - "enable_tags_feature": "Activer les étiquettes", - "enable_time_log_feature": "Activer le journal de temps", - "enable_reminders_feature": "Activer les rappels", - "pomodoro_time_log_default_text": "Session de concentration", - "toolbar_pomodoro_timer": "Minuteur de suivi du temps", - "set_code_language": "Définir le langage du code", - "cut": "Couper", - "copy": "Copier", - "paste": "Coller", - "start": "Démarrer", - "pause": "Pause", - "resume": "Reprendre", - "stop_and_log": "Arrêter et enregistrer", - "once": "une fois", - "daily": "quotidien", - "weekdays": "jours de semaine", - "weekly": "hebdomadaire", - "edit_reminder": "Modifier le rappel", - "time": "Heure", - "once": "Une fois (aujourd'hui)", - "every_day": "Tous les jours", - "every_weekday": "Tous les jours de semaine (lun-ven)", - "every_week": "Toutes les semaines", - "repeat": "Répéter", - "monday": "Lundi", - "tuesday": "Mardi", - "wednesday": "Mercredi", - "thursday": "Jeudi", - "friday": "Vendredi", - "saturday": "Samedi", - "sunday": "Dimanche", - "day": "Jour" + "db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher", + "db_issues_reported": "problème(s) signalé(s)", + "db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé", + "db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée", + "db_key_incorrect": "La clé est probablement incorrecte", + "db_database_error": "Erreur de base de données", + "database_maintenance": "Maintenance de la base de données", + "database_compact": "Compacter la base de données", + "database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.", + "database_compacted_successfully": "Base de données compactée avec succès !", + "encryption": "Chiffrement", + "remember_key": "Se souvenir de la clé", + "change_encryption_key": "Changer la clé de chiffrement", + "enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement", + "reenter_the_new_key": "Saisir de nouveau la nouvelle clé", + "key_mismatch": "Les clés ne correspondent pas", + "key_mismatch_explanation": "Les deux saisies ne correspondent pas.", + "empty_key": "La clé est vide", + "empty_key_explanation": "La clé ne peut pas être vide.", + "key_changed": "La clé a été modifiée", + "key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !", + "error": "Erreur", + "success": "Succès", + "close": "Fermer", + "find": "Rechercher", + "file": "Fichier", + "locale": "Langue", + "locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.", + "settings": "Paramètres", + "theme": "Thème", + "system": "Système", + "light": "Clair", + "dark": "Sombre", + "never": "Jamais", + "close_tab": "Fermer l'onglet", + "previous": "Précédent", + "previous_day": "Jour précédent", + "next": "Suivant", + "next_day": "Jour suivant", + "today": "Aujourd'hui", + "show": "Afficher", + "edit": "Modifier", + "delete": "Supprimer", + "history": "Historique", + "export_accessible_flag": "E&xporter", + "export_entries": "Exporter les entrées", + "export_complete": "Exportation terminée", + "export_failed": "Échec de l'exportation", + "backup": "Sauvegarder", + "backup_complete": "Sauvegarde terminée", + "backup_failed": "Échec de la sauvegarde", + "quit": "Quitter", + "cancel": "Annuler", + "save": "Enregistrer", + "help": "Aide", + "saved": "Enregistré", + "saved_to": "Enregistré dans", + "documentation": "Documentation", + "couldnt_open": "Impossible d'ouvrir", + "report_a_bug": "Signaler un bug", + "version": "Version", + "update": "Mise à jour", + "check_for_updates": "Rechercher des mises à jour", + "could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n", + "update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide", + "you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n", + "there_is_a_new_version_available": "Une nouvelle version est disponible:\n", + "download_the_appimage": "Télécharger l'AppImage ?", + "downloading": "Téléchargement en cours", + "download_cancelled": "Téléchargement annulé", + "failed_to_download_update": "Échec du téléchargement de la mise à jour:\n", + "could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n", + "could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.", + "gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n", + "downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n", + "navigate": "Naviguer", + "current": "actuel", + "selected": "sélectionné", + "find_on_page": "Rechercher dans la page", + "find_next": "Rechercher le suivant", + "find_previous": "Rechercher le précédent", + "find_bar_type_to_search": "Tapez pour rechercher", + "find_bar_match_case": "Respecter la casse", + "history_dialog_preview": "Aperçu", + "history_dialog_diff": "Différences", + "history_dialog_revert_to_selected": "Revenir à la sélection", + "history_dialog_revert_failed": "Échec de la restauration", + "history_dialog_delete": "Supprimer la révision", + "history_dialog_delete_failed": "Impossible de supprimer la révision", + "key_prompt_enter_key": "Saisir la clé", + "lock_overlay_locked": "Verrouillé", + "lock_overlay_unlock": "Déverrouiller", + "main_window_lock_screen_accessibility": "&Verrouiller l'écran", + "main_window_ready": "Prêt", + "main_window_save_a_version": "Enregistrer une version", + "main_window_settings_accessible_flag": "&Paramètres", + "set_an_encryption_key": "Définir une clé de chiffrement", + "set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !", + "unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré", + "unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin", + "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_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable", + "move_todos_include_weekends": "Autoriser le déplacement des TODO non cochées vers un week-end\nplutôt que vers le prochain jour ouvrable", + "insert_images": "Insérer des images", + "images": "Images", + "reopen_failed": "Échec de la réouverture", + "unlock_failed": "Échec du déverrouillage", + "could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.", + "unencrypted_export": "Export non chiffré", + "unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.", + "unrecognised_extension": "Extension non reconnue !", + "backup_encrypted_notebook": "Sauvegarder le bouquin chiffré", + "enter_a_name_for_this_version": "Saisir un nom pour cette version", + "new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à", + "appearance": "Apparence", + "security": "Sécurité", + "features": "Fonctionnalités", + "database": "Base de données", + "save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.", + "lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité", + "autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.", + "font_size": "Taille de police", + "font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.", + "search_for_notes_here": "Recherchez des notes ici", + "toolbar_format": "Format", + "toolbar_bold": "Gras", + "toolbar_italic": "Italique", + "toolbar_strikethrough": "Barré", + "toolbar_normal_paragraph_text": "Texte de paragraphe normal", + "toolbar_font_smaller": "Texte plus petit", + "toolbar_font_larger": "Texte plus grand", + "toolbar_bulleted_list": "Liste à puces", + "toolbar_numbered_list": "Liste numérotée", + "toolbar_code_block": "Bloc de code", + "toolbar_heading": "Titre", + "toolbar_toggle_checkboxes": "Cocher/Décocher les cases", + "tags": "Étiquettes", + "tag": "Étiquette", + "manage_tags": "Gérer les étiquettes", + "add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée", + "tag_browser_title": "Navigateur d'étiquettes", + "tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.", + "color_hex": "Couleur", + "date": "Date", + "page_or_document": "Page / Document", + "add_a_tag": "Ajouter une étiquette", + "edit_tag_name": "Modifier le nom de l'étiquette", + "new_tag_name": "Nouveau nom de l'étiquette :", + "change_color": "Changer la couleur", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.", + "tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà", + "statistics": "Statistiques", + "main_window_statistics_accessible_flag": "Stat&istiques", + "stats_group_pages": "Pages", + "stats_group_tags": "Étiquettes", + "stats_group_documents": "Documents", + "stats_group_time_logging": "Journal de temps", + "stats_group_reminders": "Rappels", + "stats_pages_with_content": "Pages avec contenu (version actuelle)", + "stats_total_revisions": "Nombre total de révisions", + "stats_page_most_revisions": "Page avec le plus de révisions", + "stats_total_words": "Nombre total de mots (versions actuelles)", + "stats_unique_tags": "Étiquettes uniques", + "stats_page_most_tags": "Page avec le plus d'étiquettes", + "stats_activity_heatmap": "Carte de chaleur d'activité", + "stats_heatmap_metric": "Colorer selon", + "stats_metric_words": "Mots", + "stats_metric_revisions": "Révisions", + "stats_metric_documents": "Documents", + "stats_total_documents": "Total des documents", + "stats_date_most_documents": "Date avec le plus de documents", + "stats_no_data": "Aucune statistique disponible pour le moment.", + "stats_time_total_hours": "Total des heures enregistrées", + "stats_time_day_most_hours": "Jour avec le plus d'heures enregistrées", + "stats_time_project_most_hours": "Projet avec le plus d'heures enregistrées", + "stats_time_activity_most_hours": "Activité avec le plus d'heures enregistrées", + "stats_total_reminders": "Total des rappels", + "stats_date_most_reminders": "Jour avec le plus de rappels", + "stats_metric_hours": "Heures", + "stats_metric_reminders": "Rappels", + "select_notebook": "Sélectionner un bouquin", + "bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.", + "bug_report_placeholder": "Saisissez votre rapport de bug ici", + "bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.", + "bug_report_send_failed": "Impossible d'envoyer le rapport de bug.", + "bug_report_sent_ok": "Rapport de bug envoyé. Merci !", + "send": "Envoyer", + "reminder": "Rappel", + "set_reminder": "Définir le rappel", + "reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !", + "invalid_time_title": "Heure invalide", + "invalid_time_message": "Veuillez saisir une heure au format HH:MM", + "dismiss": "Ignorer", + "toolbar_alarm": "Régler l'alarme de rappel", + "activities": "Activités", + "activity": "Activité", + "note": "Note", + "activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité", + "activity_delete_error_title": "Problème lors de la suppression de l'activité", + "activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité", + "activity_rename_error_title": "Problème lors du renommage de l'activité", + "activity_required_message": "Un nom d'activité est requis", + "activity_required_title": "Nom d'activité requis", + "add_activity": "Ajouter une activité", + "add_project": "Ajouter un projet", + "add_time_entry": "Ajouter une entrée de temps", + "time_period": "Période", + "dont_group": "Ne pas regrouper", + "by_activity": "par activité", + "by_day": "par jour", + "by_month": "par mois", + "by_week": "par semaine", + "date_range": "Plage de dates", + "custom_range": "Personnalisé", + "last_week": "La semaine dernière", + "this_week": "Cette semaine", + "this_month": "Ce mois-ci", + "this_year": "Cette année", + "all_projects": "Tous les projets", + "delete_activity": "Supprimer l'activité", + "delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?", + "delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?", + "delete_project": "Supprimer le projet", + "delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?", + "delete_project_title": "Supprimer le projet - êtes-vous sûr ?", + "delete_time_entry": "Supprimer l'entrée de temps", + "group_by": "Grouper par", + "hours": "Heures", + "created_at": "Créé le", + "invalid_activity_message": "L'activité est invalide", + "invalid_activity_title": "Activité invalide", + "invalid_project_message": "Le projet est invalide", + "invalid_project_title": "Projet invalide", + "manage_activities": "Gérer les activités", + "manage_projects": "Gérer les projets", + "manage_projects_activities": "Gérer les activités du projet", + "open_time_log": "Ouvrir le journal de temps", + "project": "Projet", + "project_delete_error_message": "Un problème est survenu lors de la suppression du projet", + "project_delete_error_title": "Problème lors de la suppression du projet", + "project_rename_error_message": "Un problème est survenu lors du renommage du projet", + "project_rename_error_title": "Problème lors du renommage du projet", + "project_required_message": "Un projet est requis", + "project_required_title": "Projet requis", + "projects": "Projets", + "rename_activity": "Renommer l'activité", + "rename_project": "Renommer le projet", + "reporting": "Rapports", + "reporting_and_invoicing": "Rapports et facturation", + "run_report": "Exécuter le rapport", + "add_activity_title": "Ajouter une activité", + "add_activity_label": "Ajouter une activité", + "rename_activity_label": "Renommer l'activité", + "add_project_title": "Ajouter un projet", + "add_project_label": "Ajouter un projet", + "rename_activity_title": "Renommer cette activité", + "rename_project_label": "Renommer le projet", + "rename_project_title": "Renommer ce projet", + "select_activity_message": "Sélectionner une activité", + "select_activity_title": "Sélectionner une activité", + "select_project_message": "Sélectionner un projet", + "select_project_title": "Sélectionner un projet", + "time_log": "Journal de temps", + "time_log_collapsed_hint": "Journal de temps", + "date_label": "Date : {date}", + "change_date": "Modifier la date", + "select_date_title": "Sélectionner une date", + "for": "Pour {date}", + "time_log_no_date": "Journal de temps", + "time_log_no_entries": "Aucune entrée de temps pour l'instant", + "time_log_report": "Rapport de temps", + "time_log_report_title": "Journal de temps pour {project}", + "time_log_report_meta": "Du {start} au {end}, groupé par {granularity}", + "time_log_total_hours": "Total pour la journée : {hours:.2f}h", + "time_log_with_total": "Journal de temps ({hours:.2f}h)", + "update_time_entry": "Mettre à jour l'entrée de temps", + "time_report_total": "Total : {hours:.2f} heures", + "no_report_title": "Aucun rapport", + "no_report_message": "Veuillez exécuter un rapport avant d'exporter.", + "total": "Total", + "export_csv": "Exporter en CSV", + "export_csv_error_title": "Échec de l'exportation", + "export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}", + "export_pdf": "Exporter en PDF", + "export_pdf_error_title": "Échec de l'exportation PDF", + "export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}", + "enable_tags_feature": "Activer les étiquettes", + "enable_time_log_feature": "Activer le journal de temps", + "enable_reminders_feature": "Activer les rappels", + "reminders_webhook_section_title": "Envoyer les rappels vers un webhook", + "reminders_webhook_url_label": "URL du webhook", + "reminders_webhook_secret_label": "Secret du webhook (envoyé dans l'en-tête\nX-Bouquin-Secret)", + "enable_documents_feature": "Activer le stockage des documents", + "pomodoro_time_log_default_text": "Session de concentration", + "toolbar_pomodoro_timer": "Minuteur de suivi du temps", + "set_code_language": "Définir le langage du code", + "cut": "Couper", + "copy": "Copier", + "paste": "Coller", + "start": "Démarrer", + "pause": "Pause", + "resume": "Reprendre", + "stop_and_log": "Arrêter et enregistrer", + "manage_reminders": "Gérer les rappels", + "upcoming_reminders": "Rappels à venir", + "no_upcoming_reminders": "Aucun rappel à venir", + "once": "Une fois (aujourd'hui)", + "daily": "quotidien", + "weekdays": "jours de semaine", + "weekly": "hebdomadaire", + "add_reminder": "Ajouter un rappel", + "edit_reminder": "Modifier le rappel", + "delete_reminder": "Supprimer le rappel", + "delete_reminders": "Supprimer les rappels", + "deleting_it_will_remove_all_future_occurrences": "La suppression supprimera toutes les occurrences futures.", + "this_is_a_reminder_of_type": "Note : il s'agit d'un rappel de type", + "this_will_delete_the_actual_reminders": "Note : cela supprimera les rappels eux-mêmes, pas seulement des occurrences individuelles.", + "reminders": "Rappels", + "time": "Heure", + "every_day": "Tous les jours", + "every_weekday": "Tous les jours de semaine (lun-ven)", + "every_week": "Toutes les semaines", + "every_fortnight": "Toutes les 2 semaines", + "every_month": "Chaque mois (même date)", + "every_month_nth_weekday": "Chaque mois (ex. 3e lundi)", + "week_in_month": "Semaine du mois", + "fortnightly": "Toutes les deux semaines", + "monthly_same_date": "Mensuel (même date)", + "monthly_nth_weekday": "Mensuel (nᵉ jour de semaine)", + "repeat": "Répéter", + "monday": "Lundi", + "tuesday": "Mardi", + "wednesday": "Mercredi", + "thursday": "Jeudi", + "friday": "Vendredi", + "saturday": "Samedi", + "sunday": "Dimanche", + "monday_short": "Lun", + "tuesday_short": "Mar", + "wednesday_short": "Mer", + "thursday_short": "Jeu", + "friday_short": "Ven", + "saturday_short": "Sam", + "sunday_short": "Dim", + "day": "Jour", + "text": "Texte", + "type": "Type", + "active": "Actif", + "actions": "Actions", + "edit_code_block": "Modifier le bloc de code", + "delete_code_block": "Supprimer le bloc de code", + "search_result_heading_document": "Document", + "toolbar_documents": "Gestionnaire de documents", + "project_documents_title": "Documents du projet", + "documents_col_file": "Fichier", + "documents_col_description": "Description", + "documents_col_added": "Ajouté", + "documents_col_tags": "Étiquettes", + "documents_col_size": "Taille", + "documents_add": "&Ajouter", + "documents_open": "&Ouvrir", + "documents_delete": "&Supprimer", + "documents_no_project_selected": "Veuillez d'abord choisir un projet.", + "documents_file_filter_all": "Tous les fichiers (*)", + "documents_add_failed": "Impossible d'ajouter le document : {error}", + "documents_open_failed": "Impossible d'ouvrir le document : {error}", + "documents_confirm_delete": "Retirer ce document du projet ?\n(Le fichier sur le disque ne sera pas supprimé.)", + "documents_search_label": "Rechercher", + "documents_search_placeholder": "Saisir pour rechercher des documents (tous les projets)", + "documents_invalid_date_format": "Format de date invalide", + "todays_documents": "Documents de ce jour", + "todays_documents_none": "Aucun document pour le moment.", + "manage_invoices": "Gérer les factures", + "create_invoice": "Créer une facture", + "invoice_amount": "Montant", + "invoice_apply_tax": "Appliquer la taxe", + "invoice_client_address": "Adresse du client", + "invoice_client_company": "Société cliente", + "invoice_client_email": "E-mail du client", + "invoice_client_name": "Contact client", + "invoice_currency": "Devise", + "invoice_dialog_title": "Créer une facture", + "invoice_due_date": "Date d'échéance", + "invoice_hourly_rate": "Taux horaire", + "invoice_hours": "Heures", + "invoice_issue_date": "Date d'émission", + "invoice_mode_detailed": "Mode détaillé", + "invoice_mode_summary": "Mode récapitulatif", + "invoice_number": "Numéro de facture", + "invoice_save_and_export": "Enregistrer et exporter", + "invoice_save_pdf_title": "Enregistrer le PDF", + "invoice_subtotal": "Sous-total", + "invoice_summary_default_desc": "Services de conseil pour le mois de", + "invoice_summary_desc": "Description du récapitulatif", + "invoice_summary_hours": "Heures du récapitulatif", + "invoice_tax": "Détails de la taxe", + "invoice_tax_label": "Type de taxe", + "invoice_tax_rate": "Taux de taxe", + "invoice_tax_total": "Total des taxes", + "invoice_total": "Total", + "invoice_paid_at": "Payée le", + "invoice_payment_note": "Notes de paiement", + "invoice_project_required_title": "Projet requis", + "invoice_project_required_message": "Veuillez sélectionner un projet spécifique avant d'essayer de créer une facture.", + "invoice_need_report_title": "Rapport requis", + "invoice_need_report_message": "Veuillez exécuter un rapport de temps avant d'essayer de créer une facture à partir de celui-ci.", + "invoice_due_before_issue": "La date d'échéance ne peut pas être antérieure à la date d'émission.", + "invoice_paid_before_issue": "La date de paiement ne peut pas être antérieure à la date d'émission.", + "enable_invoicing_feature": "Activer la facturation (nécessite le journal de temps)", + "invoice_company_profile": "Profil de l'entreprise", + "invoice_company_name": "Nom de l'entreprise", + "invoice_company_address": "Adresse", + "invoice_company_phone": "Téléphone", + "invoice_company_email": "E-mail", + "invoice_company_tax_id": "Numéro fiscal", + "invoice_company_payment_details": "Détails de paiement", + "invoice_company_logo": "Logo", + "invoice_company_logo_choose": "Choisir un logo", + "invoice_company_logo_set": "Le logo a été défini", + "invoice_company_logo_not_set": "Logo non défini", + "invoice_number_unique": "Le numéro de facture doit être unique. Ce numéro de facture existe déjà.", + "invoice_invalid_amount": "Le montant est invalide", + "invoice_invalid_date_format": "Format de date invalide", + "invoice_invalid_tax_rate": "Le taux de TVA est invalide", + "invoice_no_items": "La facture ne contient aucun article", + "invoice_number_required": "Un numéro de facture est requis" } diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index bec0627..3e1213c 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -532,7 +532,7 @@ class SettingsDialog(QDialog): def _on_choose_logo(self) -> None: path, _ = QFileDialog.getOpenFileName( self, - strings._("company_logo_choose"), + strings._("invoice_company_logo_choose"), "", "Images (*.png *.jpg *.jpeg *.bmp)", ) From df6ea8d139881c16fdb133edfa2bdc9bb8112258 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 16:00:57 +1100 Subject: [PATCH 07/31] Add 'Last Month' date range for timesheet reports --- CHANGELOG.md | 1 + bouquin/locales/en.json | 1 + bouquin/locales/fr.json | 1 + bouquin/time_log.py | 7 +++++++ 4 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ea976..31e7a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation * Add missing strings (for English and French) + * Add 'Last Month' date range for timesheet reports # 0.7.5 diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 8c3fa54..c8784fd 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -217,6 +217,7 @@ "date_range": "Date range", "custom_range": "Custom", "last_week": "Last week", + "last_month": "Last month", "this_week": "This week", "this_month": "This month", "this_year": "This year", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 4d304df..2b889d7 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -217,6 +217,7 @@ "date_range": "Plage de dates", "custom_range": "Personnalisé", "last_week": "La semaine dernière", + "last_month": "Le mois dernier", "this_week": "Cette semaine", "this_month": "Ce mois-ci", "this_year": "Cette année", diff --git a/bouquin/time_log.py b/bouquin/time_log.py index b93e286..1e4b303 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -1055,6 +1055,7 @@ class TimeReportDialog(QDialog): self.range_preset.addItem(strings._("today"), "today") self.range_preset.addItem(strings._("last_week"), "last_week") self.range_preset.addItem(strings._("this_week"), "this_week") + self.range_preset.addItem(strings._("last_month"), "last_month") self.range_preset.addItem(strings._("this_month"), "this_month") self.range_preset.addItem(strings._("this_year"), "this_year") self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed) @@ -1214,6 +1215,12 @@ class TimeReportDialog(QDialog): start = start_of_this_week.addDays(-7) # last week's Monday end = start_of_this_week.addDays(-1) # last week's Sunday + elif preset == "last_month": + # Previous calendar month (full month) + start_of_this_month = QDate(today.year(), today.month(), 1) + start = start_of_this_month.addMonths(-1) + end = start_of_this_month.addDays(-1) + elif preset == "this_month": start = QDate(today.year(), today.month(), 1) end = today From 757517dcc471d52ff27a4a19a0ed32659990cb15 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 16:01:23 +1100 Subject: [PATCH 08/31] Don't offer to download latest AppImage unless we are running as an AppImage already --- CHANGELOG.md | 1 + bouquin/version_check.py | 44 ++++++++++++++++++++++++------------- tests/test_version_check.py | 39 -------------------------------- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e7a3d..dc36f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation * Add missing strings (for English and French) * Add 'Last Month' date range for timesheet reports + * Don't offer to download latest AppImage unless we are running as an AppImage already # 0.7.5 diff --git a/bouquin/version_check.py b/bouquin/version_check.py index 5b62d02..be89695 100644 --- a/bouquin/version_check.py +++ b/bouquin/version_check.py @@ -95,6 +95,9 @@ class VersionChecker: """ return self._parse_version(available) > self._parse_version(current) + def _running_in_appimage(self) -> bool: + return "APPIMAGE" in os.environ + # ---------- Public entrypoint for Help → Version ---------- # def show_version_dialog(self) -> None: @@ -114,8 +117,8 @@ class VersionChecker: check_button = box.addButton( strings._("check_for_updates"), QMessageBox.ActionRole ) - box.addButton(QMessageBox.Close) + box.addButton(QMessageBox.Close) box.exec() if box.clickedButton() is check_button: @@ -159,21 +162,32 @@ class VersionChecker: 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) + if self._running_in_appimage(): + # If running in an AppImage, offer to download the new AppImage + 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) + else: + # If not running in an AppImage, just report that there's a new version. + QMessageBox.information( + self._parent, + strings._("update"), + (strings._("there_is_a_new_version_available") + available_raw), + ) + return # ---------- Download + verification helpers ---------- # def _download_file( diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 01fac35..2573020 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -173,45 +173,6 @@ def test_check_for_updates_already_latest(qtbot, app): assert mock_info.called -def test_check_for_updates_new_version_available_declined(qtbot, app): - """Test check for updates when new version is available but user declines.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.No): - # Should not proceed to download - checker.check_for_updates() - - -def test_check_for_updates_new_version_available_accepted(qtbot, app): - """Test check for updates when new version is available and user accepts.""" - parent = QWidget() - qtbot.addWidget(parent) - checker = VersionChecker(parent) - - mock_response = Mock() - mock_response.text = "2.0.0" - mock_response.raise_for_status = Mock() - - with patch("requests.get", return_value=mock_response): - with patch("importlib.metadata.version", return_value="1.0.0"): - with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes): - with patch.object( - checker, "_download_and_verify_appimage" - ) as mock_download: - checker.check_for_updates() - - # Should call download - mock_download.assert_called_once_with("2.0.0") - - def test_download_file_success(qtbot, app, tmp_path): """Test downloading a file successfully.""" checker = VersionChecker() From 807d11ca754e3e3b574af01a6d6b5ee642972a0a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:18:02 +1100 Subject: [PATCH 09/31] Add ability to collapse/expand sections of text --- .forgejo/workflows/build-deb.yml | 80 ++++++ CHANGELOG.md | 5 +- README.md | 32 ++- bouquin/code_block_editor_dialog.py | 52 +++- bouquin/locales/en.json | 4 + bouquin/locales/fr.json | 4 + bouquin/markdown_editor.py | 389 ++++++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 20 deletions(-) create mode 100644 .forgejo/workflows/build-deb.yml diff --git a/.forgejo/workflows/build-deb.yml b/.forgejo/workflows/build-deb.yml new file mode 100644 index 0000000..0f44886 --- /dev/null +++ b/.forgejo/workflows/build-deb.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl gnupg2 ca-certificates + mkdir -p /usr/share/keyrings + curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor -o /usr/share/keyrings/mig5.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net trixie main" | tee /etc/apt/sources.list.d/mig5.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential \ + devscripts \ + debhelper \ + dh-python \ + python3-all-dev \ + python3-setuptools \ + python3-wheel \ + libssl-dev \ + rsync \ + pybuild-plugin-pyproject \ + python3-poetry-core \ + python3-sqlcipher4 \ + python3-pyside6.qtwidgets \ + python3-pyside6.qtcore \ + python3-pyside6.qtgui \ + python3-pyside6.qtsvg \ + python3-pyside6.qtprintsupport \ + python3-requests \ + python3-markdown \ + libxcb-cursor0 \ + fonts-noto-core + + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build deb + run: | + mkdir /out + + rsync -a --delete \ + --exclude '.git' \ + --exclude '.venv' \ + --exclude 'dist' \ + --exclude 'build' \ + --exclude '__pycache__' \ + --exclude '.pytest_cache' \ + --exclude '.mypy_cache' \ + ./ /out/ + + cd /out/ + export DEBEMAIL="mig@mig5.net" + export DEBFULLNAME="Miguel Jacq" + + dch --distribution "trixie" --local "~trixie" "CI build for trixie" + dpkg-buildpackage -us -uc -b + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/CHANGELOG.md b/CHANGELOG.md index dc36f0f..d94ebeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ -# 0.7.6 +# 0.8.0 * Add .desktop file for Debian * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation - * Add missing strings (for English and French) + * Add ability to collapse/expand sections of text. * Add 'Last Month' date range for timesheet reports + * Add missing strings (for English and French) * Don't offer to download latest AppImage unless we are running as an AppImage already # 0.7.5 diff --git a/README.md b/README.md index a7b5ec0..1019d48 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ It is designed to treat each day as its own 'page', complete with Markdown rende search, reminders and time logging for those of us who need to keep track of not just TODOs, but also how long we spent on them. +For those who rely on that time logging for work, there is also an Invoicing feature that can +generate invoices of that time spent. + +There is also support for embedding documents in a file manager. + It uses SQLCipher as a drop-in replacement for SQLite3. This means that the underlying database for the notebook is encrypted at rest. @@ -52,16 +57,18 @@ report from within the app, or optionally to check for new versions to upgrade t -## Some of the features +## Features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings * All changes are version controlled, with ability to view/diff versions, revert or delete revisions - * Automatic rendering of basic Markdown syntax * Tabs are supported - right-click on a date from the calendar to open it in a new tab. + * Automatic rendering of basic Markdown syntax + * Basic code block editing/highlighting + * Ability to collapse/expand sections of text + * Ability to increase/decrease font size * Images are supported * Search all pages, or find text on current page - * Add and manage tags * Automatic periodic saving (or explicitly save) * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) @@ -69,11 +76,12 @@ report from within the app, or optionally to check for new versions to upgrade t * 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 the last 7 days to the next weekday. + * It is possible to automatically move unchecked checkboxes from the last 7 days to the next day. * English, French and Italian locales provided - * Ability to set reminder alarms (which will be flashed as the reminder) - * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports + * Ability to set reminder alarms (which will be flashed as the reminder or can be sent as webhooks/email notifications) + * Ability to log time per day for different projects/activities, pomodoro-style log timer, timesheet reports and invoicing of time spent * Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database. + * Add and manage tags on pages and documents ## How to install @@ -92,7 +100,6 @@ sudo apt update sudo apt install bouquin ``` - ### From PyPi/pip * `pip install bouquin` @@ -108,13 +115,4 @@ sudo apt install bouquin * Run `poetry install` to install dependencies * Run `poetry run bouquin` to start the application. -### From the releases page - - * Download the whl and run it - -## How to run the tests - - * Clone the repo - * Ensure you have poetry installed - * Run `poetry install --with test` - * Run `./tests.sh` +Alternatively, you can download the source code and wheels from Releases as well. diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py index 8df348d..64bb46b 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re + from PySide6.QtCore import QRect, QSize, Qt -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -32,6 +34,12 @@ class CodeEditorWithLineNumbers(QPlainTextEdit): def __init__(self, parent=None): super().__init__(parent) + # Allow Tab to insert indentation (not move focus between widgets) + self.setTabChangesFocus(False) + + # Track whether we just auto-inserted indentation on Enter + self._last_enter_was_empty_indent = False + self._line_number_area = _LineNumberArea(self) self.blockCountChanged.connect(self._update_line_number_area_width) @@ -140,6 +148,48 @@ class CodeEditorWithLineNumbers(QPlainTextEdit): bottom = top + self.blockBoundingRect(block).height() block_number += 1 + def keyPressEvent(self, event): # type: ignore[override] + """Auto-retain indentation on newlines (Tab/space) like the markdown editor. + + Rules: + - If the current line is indented, Enter inserts a newline + the same indent. + - If the current line contains only indentation, a *second* Enter clears the indent + and starts an unindented line (similar to exiting bullets/checkboxes). + """ + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + cursor = self.textCursor() + block_text = cursor.block().text() + indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr] + + if indent: + rest = block_text[len(indent) :] + indent_only = rest.strip() == "" + + if indent_only and self._last_enter_was_empty_indent: + # Second Enter on an indentation-only line: remove that line and + # start a fresh, unindented line. + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.insertText("\n") + self.setTextCursor(cursor) + self._last_enter_was_empty_indent = False + return + + # First Enter: keep indentation + super().keyPressEvent(event) + self.textCursor().insertText(indent) + self._last_enter_was_empty_indent = True + return + + # No indent -> normal Enter + self._last_enter_was_empty_indent = False + super().keyPressEvent(event) + return + + # Any other key resets the empty-indent-enter flag + self._last_enter_was_empty_indent = False + super().keyPressEvent(event) + class CodeBlockEditorDialog(QDialog): def __init__( diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index c8784fd..26a4d5c 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -303,6 +303,10 @@ "cut": "Cut", "copy": "Copy", "paste": "Paste", + "collapse": "Collapse", + "expand": "Expand", + "remove_collapse": "Remove collapse", + "collapse_selection": "Collapse selection", "start": "Start", "pause": "Pause", "resume": "Resume", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 2b889d7..d82890d 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -302,6 +302,10 @@ "cut": "Couper", "copy": "Copier", "paste": "Coller", + "collapse": "Replier", + "expand": "Déplier", + "remove_collapse": "Supprimer le pliage", + "collapse_selection": "Replier la sélection", "start": "Démarrer", "pause": "Pause", "resume": "Reprendre", diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 78af734..849f515 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -34,6 +34,22 @@ class MarkdownEditor(QTextEdit): _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + # ===== Collapsible sections (editor-only folding) ===== + # We represent a collapsed region as: + # ▸ collapse + # ... hidden blocks ... + # + # + # The end-marker line is always hidden in the editor but preserved in markdown. + _COLLAPSE_ARROW_COLLAPSED = "▸" + _COLLAPSE_ARROW_EXPANDED = "▾" + _COLLAPSE_LABEL_COLLAPSE = "collapse" + _COLLAPSE_LABEL_EXPAND = "expand" + _COLLAPSE_END_MARKER = "" + # Accept either "collapse" or "expand" in the header text (older files used only "collapse") + _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") + _COLLAPSE_END_RE = re.compile(r"^([ \t]*)\s*$") + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) @@ -703,6 +719,9 @@ class MarkdownEditor(QTextEdit): # Render any embedded images self._render_images() + # Apply folding for any collapse regions present in the markdown + self._refresh_collapse_folding() + self._update_code_block_row_backgrounds() QTimer.singleShot(0, self._update_code_block_row_backgrounds) @@ -1328,6 +1347,45 @@ class MarkdownEditor(QTextEdit): block = cur.block() text = block.text() + # Click-to-toggle collapse regions: clicking the arrow on a + # "▸ collapse" / "▾ collapse" line expands/collapses the section. + parsed = self._parse_collapse_header(text) + if parsed: + indent, _is_collapsed = parsed + arrow_idx = len(indent) + if arrow_idx < len(text): + arrow = text[arrow_idx] + if arrow in ( + self._COLLAPSE_ARROW_COLLAPSED, + self._COLLAPSE_ARROW_EXPANDED, + ): + doc_pos = block.position() + arrow_idx + c_arrow = QTextCursor(self.document()) + c_arrow.setPosition( + max( + 0, + min( + doc_pos, + max(0, self.document().characterCount() - 1), + ), + ) + ) + r = self.cursorRect(c_arrow) + + fmt_font = ( + c_arrow.charFormat().font() + if c_arrow.charFormat().isValid() + else self.font() + ) + fm = QFontMetrics(fmt_font) + w = max(1, fm.horizontalAdvance(arrow)) + + # Make the hit area a bit generous. + hit_rect = QRect(r.x(), r.y(), w + (w // 2), r.height()) + if hit_rect.contains(pt): + self._toggle_collapse_at_block(block) + return + # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space) unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} " checked = f"{self._CHECK_CHECKED_DISPLAY} " @@ -1789,6 +1847,307 @@ class MarkdownEditor(QTextEdit): cursor.insertImage(img_format) cursor.insertText("\n") # Add newline after image + # ========== Collapse / Expand (folding) ========== + + def _parse_collapse_header(self, line: str) -> Optional[tuple[str, bool]]: + # If line is a collapse header, return (indent, is_collapsed) + m = self._COLLAPSE_HEADER_RE.match(line) + if not m: + return None + indent = m.group(1) + arrow = m.group(2) + return (indent, arrow == self._COLLAPSE_ARROW_COLLAPSED) + + def _is_collapse_end_marker(self, line: str) -> bool: + return bool(self._COLLAPSE_END_RE.match(line)) + + def _set_block_visible(self, block: QTextBlock, visible: bool) -> None: + """Hide/show a QTextBlock and nudge layout to update. + + When folding, we set lineCount=0 for hidden blocks (standard Qt recipe). + When showing again, we restore a sensible lineCount based on the block's + current layout so the document relayout doesn't glitch. + """ + if not block.isValid(): + return + if block.isVisible() == visible: + return + + block.setVisible(visible) + + try: + if not visible: + # Hidden blocks should contribute no height. + block.setLineCount(0) # type: ignore[attr-defined] + else: + # Restore an accurate lineCount if we can. + layout = block.layout() + lc = 1 + try: + lc = int(layout.lineCount()) if layout is not None else 1 + except Exception: + lc = 1 + block.setLineCount(max(1, lc)) # type: ignore[attr-defined] + except Exception: + pass + + doc = self.document() + if doc is not None: + doc.markContentsDirty(block.position(), block.length()) + + def _find_collapse_end_block( + self, header_block: QTextBlock + ) -> Optional[QTextBlock]: + # Find matching end marker for a header (supports nesting) + if not header_block.isValid(): + return None + + depth = 1 + b = header_block.next() + while b.isValid(): + line = b.text() + if self._COLLAPSE_HEADER_RE.match(line): + depth += 1 + elif self._is_collapse_end_marker(line): + depth -= 1 + if depth == 0: + return b + b = b.next() + return None + + def _set_collapse_header_state( + self, header_block: QTextBlock, collapsed: bool + ) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + indent, _ = parsed + arrow = ( + self._COLLAPSE_ARROW_COLLAPSED + if collapsed + else self._COLLAPSE_ARROW_EXPANDED + ) + label = ( + self._COLLAPSE_LABEL_EXPAND if collapsed else self._COLLAPSE_LABEL_COLLAPSE + ) + new_line = f"{indent}{arrow} {label}" + + # Replace *only* the text inside this block (not the paragraph separator), + # to avoid any chance of the header visually "joining" adjacent lines. + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + cursor.setPosition(header_block.position()) + cursor.beginEditBlock() + cursor.movePosition( + QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor + ) + cursor.insertText(new_line) + cursor.endEditBlock() + + def _toggle_collapse_at_block(self, header_block: QTextBlock) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + + doc = self.document() + if doc is None: + return + + block_num = header_block.blockNumber() + _, is_collapsed = parsed + + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + # Flip header arrow + self._set_collapse_header_state(header_block, collapsed=not is_collapsed) + + # Refresh folding so nested regions keep their state + self._refresh_collapse_folding() + + # Re-resolve the header block after edits/layout changes + hb = doc.findBlockByNumber(block_num) + pos = hb.position() if hb.isValid() else header_block.position() + + # Keep caret on the header (start of line) + c = self.textCursor() + c.setPosition(max(0, min(pos, max(0, doc.characterCount() - 1)))) + self.setTextCursor(c) + self.setFocus() + + def _remove_collapse_at_block(self, header_block: QTextBlock) -> None: + # Remove a collapse wrapper (keep content, delete header + end marker) + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + doc = self.document() + if doc is None: + return + + # Ensure content visible + b = header_block.next() + while b.isValid() and b != end_block: + self._set_block_visible(b, True) + b = b.next() + + cur = QTextCursor(doc) + cur.beginEditBlock() + + # Delete header block + cur.setPosition(header_block.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() # paragraph separator + + # Find and delete the end marker block (scan forward) + probe = doc.findBlock(end_block.position()) + b2 = probe + for _ in range(0, 50): + if not b2.isValid(): + break + if self._is_collapse_end_marker(b2.text()): + cur.setPosition(b2.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() + break + b2 = b2.next() + + cur.endEditBlock() + + self._refresh_collapse_folding() + + def collapse_selection(self) -> None: + # Wrap the current selection in a collapsible region and collapse it + cursor = self.textCursor() + if not cursor.hasSelection(): + return + + doc = self.document() + if doc is None: + return + + sel_start = min(cursor.selectionStart(), cursor.selectionEnd()) + sel_end = max(cursor.selectionStart(), cursor.selectionEnd()) + + # Defensive clamp (prevents QTextCursor::setPosition out-of-range in edge cases) + doc_end = max(0, doc.characterCount() - 1) + sel_start = max(0, min(sel_start, doc_end)) + sel_end = max(0, min(sel_end, doc_end)) + + c1 = QTextCursor(doc) + c1.setPosition(sel_start) + start_block = c1.block() + + c2 = QTextCursor(doc) + c2.setPosition(sel_end) + end_block = c2.block() + + # If the selection ends exactly at the start of a block, treat the + # previous block as the "end" (Qt selections often report the start + # of the next block as selectionEnd()). + if ( + sel_end > sel_start + and end_block.isValid() + and sel_end == end_block.position() + and sel_end > 0 + ): + c2.setPosition(sel_end - 1) + end_block = c2.block() + + # Expand to whole blocks + start_pos = start_block.position() + end_pos_raw = end_block.position() + end_block.length() + end_pos = min(end_pos_raw, max(0, doc.characterCount() - 1)) + + # Inherit indentation from the first selected line (useful inside lists) + m = re.match(r"^[ \t]*", start_block.text()) + indent = m.group(0) if m else "" + + header_line = ( + f"{indent}{self._COLLAPSE_ARROW_COLLAPSED} {self._COLLAPSE_LABEL_EXPAND}" + ) + end_marker_line = f"{indent}{self._COLLAPSE_END_MARKER}" + + edit = QTextCursor(doc) + edit.beginEditBlock() + + # Insert end marker AFTER selection first (keeps start positions stable) + edit.setPosition(end_pos) + + # If the computed end position fell off the end of the document (common + # when the selection includes the last line without a trailing newline), + # ensure the end marker starts on its own line. + if end_pos_raw > end_pos and edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + # Also ensure we are not mid-line (marker should be its own block). + if edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + edit.insertText(end_marker_line + "\n") + + # Insert header BEFORE selection + edit.setPosition(start_pos) + edit.insertText(header_line + "\n") + edit.endEditBlock() + + self._refresh_collapse_folding() + + # Caret on header + header_block = doc.findBlock(start_pos) + c = self.textCursor() + c.setPosition(header_block.position()) + self.setTextCursor(c) + self.setFocus() + + def _refresh_collapse_folding(self) -> None: + # Apply folding to all collapse regions based on their arrow state + doc = self.document() + if doc is None: + return + + # Show everything except end markers (always hidden) + b = doc.begin() + while b.isValid(): + if self._is_collapse_end_marker(b.text()): + self._set_block_visible(b, False) + else: + self._set_block_visible(b, True) + b = b.next() + + # Hide content for any header that is currently collapsed + b = doc.begin() + while b.isValid(): + parsed = self._parse_collapse_header(b.text()) + if parsed and parsed[1] is True: + end_block = self._find_collapse_end_block(b) + if end_block is None: + b = b.next() + continue + + inner = b.next() + while inner.isValid() and inner != end_block: + self._set_block_visible(inner, False) + inner = inner.next() + + self._set_block_visible(end_block, False) + b = end_block + b = b.next() + + # Force a full relayout after visibility changes (prevents visual jitter) + doc.markContentsDirty(0, doc.characterCount()) + self.viewport().update() + # ========== Context Menu Support ========== def contextMenuEvent(self, event): @@ -1832,6 +2191,36 @@ class MarkdownEditor(QTextEdit): menu.addSeparator() + # Collapse / Expand actions + header_parsed = self._parse_collapse_header(block.text()) + if header_parsed: + _indent, is_collapsed = header_parsed + + menu.addSeparator() + + toggle_label = ( + strings._("expand") if is_collapsed else strings._("collapse") + ) + toggle_action = QAction(toggle_label, self) + toggle_action.triggered.connect( + lambda checked=False, b=block: self._toggle_collapse_at_block(b) + ) + menu.addAction(toggle_action) + + remove_action = QAction(strings._("remove_collapse"), self) + remove_action.triggered.connect( + lambda checked=False, b=block: self._remove_collapse_at_block(b) + ) + menu.addAction(remove_action) + + menu.addSeparator() + + if self.textCursor().hasSelection(): + collapse_sel_action = QAction(strings._("collapse_selection"), self) + collapse_sel_action.triggered.connect(self.collapse_selection) + menu.addAction(collapse_sel_action) + menu.addSeparator() + # Add standard context menu actions if self.textCursor().hasSelection(): menu.addAction(strings._("cut"), self.cut) From 4ae97975884915508b814ac58b75777c3590551b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:26:36 +1100 Subject: [PATCH 10/31] Remove unneeded tests-debian-packaging.sh, we have it in CI now --- tests-debian-packaging.sh | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100755 tests-debian-packaging.sh diff --git a/tests-debian-packaging.sh b/tests-debian-packaging.sh deleted file mode 100755 index cbefc31..0000000 --- a/tests-debian-packaging.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -set -eou pipefail - -DISTS=( - debian:trixie -) - -for dist in ${DISTS[@]}; do - release=$(echo ${dist} | cut -d: -f2) - mkdir -p dist/${release} - - docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \ - --no-cache \ - --progress=plain \ - --build-arg BASE_IMAGE=${dist} . - - docker run --rm \ - -e SUITE="${release}" \ - -v "$PWD":/src \ - -v "$PWD/dist/${release}":/out \ - bouquin-deb:${release} - - debfile=$(ls -1 dist/${release}/*.deb) -done From ab1ed558309c941911dc7a9680bf7e38ed3d89ee Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:29:12 +1100 Subject: [PATCH 11/31] README.md clarifications --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1019d48..4a52ef4 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ report from within the app, or optionally to check for new versions to upgrade t ## How to install -Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). +Unless you are using the Debian option below: -If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). + * Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). + * If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). ### Debian 13 ('Trixie') From 5f18b6daec8af64e8f2a0f4ce537f59837db14bf Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:33:19 +1100 Subject: [PATCH 12/31] prep changelog for debian package --- debian/changelog | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/debian/changelog b/debian/changelog index 146e349..0216393 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,16 @@ +bouquin (0.8.0) unstable; urgency=medium + + * Add .desktop file for Debian + * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) + * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) + * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation + * Add ability to collapse/expand sections of text. + * Add 'Last Month' date range for timesheet reports + * Add missing strings (for English and French) + * Don't offer to download latest AppImage unless we are running as an AppImage already + + -- Miguel Jacq Tue, 23 Dec 2025 17:30:00 +1100 + bouquin (0.7.5) unstable; urgency=medium * Add libxcb-cursor0 dependency From c853be5eff764de94b9f8d4061c5279dc930c1aa Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:53:26 +1100 Subject: [PATCH 13/31] More tests --- tests/test_code_block_editor_dialog.py | 43 ++++++- tests/test_markdown_editor.py | 157 +++++++++++++++++++++++++ tests/test_pomodoro_timer.py | 27 ++++- tests/test_time_log.py | 20 ++++ 4 files changed, 244 insertions(+), 3 deletions(-) diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py index e64199b..1ced14c 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -3,8 +3,8 @@ from bouquin.code_block_editor_dialog import ( CodeBlockEditorDialog, CodeEditorWithLineNumbers, ) -from PySide6.QtCore import QRect, QSize -from PySide6.QtGui import QFont, QPaintEvent +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QFont, QPaintEvent, QTextCursor from PySide6.QtWidgets import QPushButton @@ -323,3 +323,42 @@ def test_code_editor_viewport_margins(qtbot, app): assert margins.top() == 0 assert margins.right() == 0 assert margins.bottom() == 0 + + +def test_code_editor_retains_indentation_on_enter(qtbot, app): + """Pressing Enter on an indented line retains indentation in code editor.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("\tfoo") + editor.show() + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\t") + + +def test_code_editor_double_enter_on_empty_indent_resets(qtbot, app): + """Second Enter on an indentation-only line clears the indent in code editor.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("\tfoo") + editor.show() + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + assert editor.toPlainText().endswith("\tfoo\n\t") + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\n") + assert editor.textCursor().block().text() == "" diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index dcacbc5..a36a09e 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -150,6 +150,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor): assert "\n\u2022 " in txt +def test_tab_indentation_is_retained_on_newline(editor, qtbot): + """Pressing Enter on an indented line should retain the indentation.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("\tfoo") + editor.moveCursor(QTextCursor.End) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\t") + + +def test_double_enter_on_empty_indented_line_resets_indent(editor, qtbot): + """A second Enter on an indentation-only line should reset to column 0.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("\tfoo") + editor.moveCursor(QTextCursor.End) + + # First Enter inserts a new indented line + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + assert editor.toPlainText().endswith("\tfoo\n\t") + + # Second Enter on the now-empty indented line removes the indent + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\n") + # Cursor should be on a fresh unindented blank line + assert editor.textCursor().block().text() == "" + + +def test_nested_list_continuation_preserves_indentation(editor, qtbot): + """Enter on an indented bullet should keep indent + bullet prefix.""" + qtbot.addWidget(editor) + editor.show() + editor.from_markdown("\t- item") + editor.moveCursor(QTextCursor.End) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert "\n\t\u2022 " in editor.toPlainText() + + def test_enter_on_empty_list_marks_empty(qtbot, editor): qtbot.addWidget(editor) editor.show() @@ -181,6 +228,116 @@ def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, assert t == "" +def _find_first_block(doc, predicate): + b = doc.begin() + while b.isValid(): + if predicate(b): + return b + b = b.next() + return None + + +def test_collapse_selection_wraps_and_hides_blocks(editor, qtbot): + """Collapsing a selection should insert header/end marker and hide content.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc\n") + doc = editor.document() + + # Select lines b..c + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(b_block.position()) + cur.setPosition(c_block.position() + c_block.length() - 1, QTextCursor.KeepAnchor) + editor.setTextCursor(cur) + + editor.collapse_selection() + qtbot.wait(0) + + # Header and end marker should exist as their own blocks + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + assert "▸" in header.text() and "expand" in header.text() + + end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text()) + assert end_marker is not None + + # Inner blocks should be hidden; end marker always hidden + inner1 = header.next() + inner2 = inner1.next() + assert inner1.text() == "b" + assert inner2.text() == "c" + assert inner1.isVisible() is False + assert inner2.isVisible() is False + assert end_marker.isVisible() is False + + +def test_toggle_collapse_expands_and_updates_header(editor, qtbot): + """Toggling a collapse header should reveal hidden blocks and flip label.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc\n") + doc = editor.document() + + # Select b..c and collapse + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(b_block.position(), QTextCursor.MoveMode.MoveAnchor) + cur.setPosition( + c_block.position() + c_block.length() - 1, QTextCursor.MoveMode.KeepAnchor + ) + editor.setTextCursor(cur) + editor.collapse_selection() + qtbot.wait(0) + + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + + # Toggle to expand + editor._toggle_collapse_at_block(header) + qtbot.wait(0) + + header2 = doc.findBlock(header.position()) + assert "▾" in header2.text() and "collapse" in header2.text() + assert header2.next().isVisible() is True + assert header2.next().next().isVisible() is True + + +def test_collapse_selection_without_trailing_newline_keeps_marker_on_own_line( + editor, qtbot +): + """Selections reaching EOF without a trailing newline should still fold correctly.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc") # no trailing newline + doc = editor.document() + + # Bottom-up selection of last two lines (c..b) + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(c_block.position() + len(c_block.text())) + cur.setPosition(b_block.position(), QTextCursor.KeepAnchor) + editor.setTextCursor(cur) + + editor.collapse_selection() + qtbot.wait(0) + + end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text()) + assert end_marker is not None + + # End marker is its own block, and remains hidden + assert end_marker.text().strip() == "" + assert end_marker.isVisible() is False + + # The last content line should be hidden (folded) + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + assert header.next().isVisible() is False + + def test_down_escapes_from_last_code_line(editor, qtbot): editor.from_markdown("```\nLINE\n```\n") # Put caret at end of "LINE" diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 1c2e450..1dd4d95 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -276,7 +276,7 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): - """Elapsed time should be rounded up to the nearest 0.25 hours.""" + """Elapsed time should be rounded to a 0.25-hour increment.""" parent = DummyMainWindow(app) qtbot.addWidget(parent) qtbot.addWidget(parent.time_log) @@ -300,6 +300,31 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey assert hours_set * 4 == int(hours_set * 4) +def test_seconds_to_logged_hours_nearest_quarter_rounding(): + """Seconds -> hours uses nearest-quarter rounding with a 15-min minimum.""" + # Import the pure conversion helper directly (no Qt required) + from bouquin.pomodoro_timer import PomodoroManager + + # <15 minutes always rounds up to 0.25 + assert PomodoroManager._seconds_to_logged_hours(1) == 0.25 + assert PomodoroManager._seconds_to_logged_hours(899) == 0.25 + + # 15 minutes exact + assert PomodoroManager._seconds_to_logged_hours(900) == 0.25 + + # Examples from the spec: closest quarter-hour + # 33 minutes -> closer to 0.50 than 0.75 + assert PomodoroManager._seconds_to_logged_hours(33 * 60) == 0.50 + # 40 minutes -> closer to 0.75 than 0.50 + assert PomodoroManager._seconds_to_logged_hours(40 * 60) == 0.75 + + # Halfway case: 22.5 min is exactly between 0.25 and 0.50 -> round up + assert PomodoroManager._seconds_to_logged_hours(int(22.5 * 60)) == 0.50 + + # Sanity: 1 hour stays 1.0 + assert PomodoroManager._seconds_to_logged_hours(60 * 60) == 1.00 + + def test_pomodoro_manager_on_timer_stopped_prefills_note( qtbot, app, fresh_db, monkeypatch ): diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 45db626..b03029e 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1211,6 +1211,26 @@ def test_time_report_dialog_default_date_range(qtbot, fresh_db): assert dialog.to_date.date() == today +def test_time_report_dialog_last_month_preset_sets_full_previous_month(qtbot, fresh_db): + """Selecting 'Last month' sets the date range to the previous calendar month.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + idx = dialog.range_preset.findData("last_month") + assert idx != -1 + + today = QDate.currentDate() + start_of_this_month = QDate(today.year(), today.month(), 1) + expected_start = start_of_this_month.addMonths(-1) + expected_end = start_of_this_month.addDays(-1) + + dialog.range_preset.setCurrentIndex(idx) + + assert dialog.from_date.date() == expected_start + assert dialog.to_date.date() == expected_end + + def test_time_report_dialog_run_report(qtbot, fresh_db): """Run a time report.""" strings.load_strings("en") From 4fda9833ed6b4b907b8706d2822dbcc90c4b2edc Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:54:06 +1100 Subject: [PATCH 14/31] Bump to 0.8.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index deeaa8a..f4592bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.7.5" +version = "0.8.0" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" From 9b457278f93c6908483bbf953163e78e955b54b2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 24 Dec 2025 15:26:41 +1100 Subject: [PATCH 15/31] Add rpm --- Dockerfile.rpmbuild | 108 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 19 ++++++++ poetry.lock | 2 +- pyproject.toml | 4 +- release.sh | 24 ++++++++++ rpm/bouquin.spec | 86 +++++++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.rpmbuild create mode 100644 rpm/bouquin.spec diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild new file mode 100644 index 0000000..01201c1 --- /dev/null +++ b/Dockerfile.rpmbuild @@ -0,0 +1,108 @@ +# syntax=docker/dockerfile:1 +FROM fedora:42 + +# rpmbuild in a container does not auto-install BuildRequires. Since we're +# building directly in Docker (not mock), we pre-install the common deps that +# Fedora's pyproject macros will require for Bouquin. +# +# NOTE: bouquin also needs python3dist(sqlcipher4) at build time (because +# %pyproject_buildrequires includes runtime deps). That one is NOT in Fedora; +# we install it from /deps. +RUN set -eux; \ + dnf -y update; \ + dnf -y install \ + rpm-build rpmdevtools \ + redhat-rpm-config \ + gcc \ + make \ + findutils \ + tar \ + gzip \ + rsync \ + python3 \ + python3-devel \ + python3-pip \ + python3-setuptools \ + python3-wheel \ + pyproject-rpm-macros \ + python3-rpm-macros \ + python3-poetry-core \ + desktop-file-utils \ + python3-requests \ + python3-markdown \ + python3-pyside6 \ + xcb-util-cursor ; \ + dnf -y clean all + +RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +SRC="${SRC:-/src}" +WORKROOT="${WORKROOT:-/work}" +OUT="${OUT:-/out}" +DEPS_DIR="${DEPS_DIR:-/deps}" + +# Install bouquin-sqlcipher4 from local rpm +# Filter out .src.rpm and debug* subpackages if present. +if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then + mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)') + if [ "${#rpms[@]}" -gt 0 ]; then + echo "Installing dependency RPMs from ${DEPS_DIR}:" + printf ' - %s\n' "${rpms[@]}" + dnf -y install "${rpms[@]}" + dnf -y clean all + else + echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2 + fi +else + echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(sqlcipher4)," >&2 + echo " mount your bouquin-sqlcipher4 RPM directory as -v :/deps" >&2 +fi + +mkdir -p "${WORKROOT}" "${OUT}" +WORK="${WORKROOT}/src" +rm -rf "${WORK}" +mkdir -p "${WORK}" + +rsync -a --delete \ + --exclude '.git' \ + --exclude '.venv' \ + --exclude 'dist' \ + --exclude 'build' \ + --exclude '__pycache__' \ + --exclude '.pytest_cache' \ + --exclude '.mypy_cache' \ + "${SRC}/" "${WORK}/" + +cd "${WORK}" + +# Determine version from pyproject.toml unless provided +if [ -n "${VERSION:-}" ]; then + ver="${VERSION}" +else + ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')" +fi + +TOPDIR="${WORKROOT}/rpmbuild" +mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + +tarball="${TOPDIR}/SOURCES/bouquin-${ver}.tar.gz" +tar -czf "${tarball}" --transform "s#^#bouquin/#" . + +cp -v "rpm/bouquin.spec" "${TOPDIR}/SPECS/bouquin.spec" + +rpmbuild -ba "${TOPDIR}/SPECS/bouquin.spec" \ + --define "_topdir ${TOPDIR}" \ + --define "upstream_version ${ver}" + +shopt -s nullglob +cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true +cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true +echo "Artifacts copied to ${OUT}" +EOF + +RUN chmod +x /usr/local/bin/build-rpm + +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/build-rpm"] diff --git a/README.md b/README.md index 4a52ef4..7d4e55f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,25 @@ sudo apt update sudo apt install bouquin ``` +### Fedora 42 + +```bash +sudo rpm --import https://mig5.net/static/mig5.asc + +sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF' +[mig5] +name=mig5 Repository +baseurl=https://rpm.mig5.net/rpm/$basearch +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=https://mig5.net/static/mig5.asc +EOF + +sudo dnf upgrade --refresh +sudo dnf install bouquin +``` + ### From PyPi/pip * `pip install bouquin` diff --git a/poetry.lock b/poetry.lock index d2932af..c320489 100644 --- a/poetry.lock +++ b/poetry.lock @@ -640,4 +640,4 @@ zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.14" -content-hash = "0241cd7378c45e79da728a23b89defa18f776ada9af1e60f2a19b0d90f3a2c19" +content-hash = "0ce61476b8c35f864d395ab3a4ee4645a1ad2e16aa800cba5e77e5bfee23d6a6" diff --git a/pyproject.toml b/pyproject.toml index f4592bc..9adca9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,8 @@ include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/Not python = ">=3.10,<3.14" pyside6 = ">=6.8.1,<7.0.0" bouquin-sqlcipher4 = "^4.12.0" -requests = "^2.32.5" -markdown = "^3.10" +requests = "^2.32.3" +markdown = "^3.7" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" diff --git a/release.sh b/release.sh index 2fab9ac..d358e80 100755 --- a/release.sh +++ b/release.sh @@ -69,4 +69,28 @@ for dist in ${DISTS[@]}; do reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" done +# RPM +sudo apt-get -y install createrepo-c rpm +docker build -f Dockerfile.rpmbuild -t bouquin-rpm:f42 --progress=plain . +docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:f42 +REPO_ROOT="${HOME}/git/repo_rpm" +RPM_REPO="${REPO_ROOT}/rpm/x86_64" +BUILD_OUTPUT="${HOME}/git/bouquin/dist" +REMOTE="letessier.mig5.net:/opt/repo_rpm" +KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" + +echo "==> Updating RPM repo..." +mkdir -p "$RPM_REPO" +cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" + +createrepo_c "$RPM_REPO" + +echo "==> Signing repomd.xml..." +qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" + +echo "==> Syncing repo to server..." +rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" + +echo "Done!" + ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt" diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec new file mode 100644 index 0000000..341d80d --- /dev/null +++ b/rpm/bouquin.spec @@ -0,0 +1,86 @@ +# bouquin Fedora 42 RPM spec using Fedora's pyproject RPM macros (Poetry backend). +# +# NOTE: Bouquin depends on "bouquin-sqlcipher4" project, but the RPM actually +# provides the Python distribution/module as "sqlcipher4". To keep Fedora's +# auto-generated python3dist() Requires correct, we rewrite the dependency key in +# pyproject.toml at build time. +%global upstream_version 0.8.0 + +Name: bouquin +Version: %{upstream_version} +Release: 1%{?dist}.bouquin1 +Summary: A simple, opinionated notebook application (Python/Qt/SQLCipher) + +License: GPL-3.0-or-later +URL: https://git.mig5.net/mig5/bouquin +Source0: %{name}-%{version}.tar.gz + +BuildArch: noarch + +BuildRequires: pyproject-rpm-macros +BuildRequires: python3-devel +BuildRequires: python3-poetry-core +BuildRequires: desktop-file-utils + +# Non-Python runtime dep (Fedora equivalent of Debian's libxcb-cursor0) +Requires: xcb-util-cursor + +# Make sure private repo dependency is pulled in by package name as well. +Requires: python3-sqlcipher4 >= 4.12.0 + +%description +Bouquin is a simple, opinionated notebook application written in Python and Qt, +storing data using SQLCipher. + +%prep +%autosetup -n bouquin + +# Patch dependency name so Fedora's python dependency generator targets the +# provider from bouquin-sqlcipher4 RPM (python3dist(sqlcipher4)). +%{python3} - <<'PY' +from pathlib import Path +import re + +p = Path("pyproject.toml") +txt = p.read_text(encoding="utf-8") + +pattern = re.compile(r'(?ms)(^\[tool\.poetry\.dependencies\]\n.*?)(^\[|\Z)') +m = pattern.search(txt) +if not m: + raise SystemExit("Could not locate [tool.poetry.dependencies] in pyproject.toml") + +deps_block = m.group(1) +deps_block2 = re.sub(r'(?m)^bouquin-sqlcipher4\s*=\s*(".*?")\s*$', r'sqlcipher4 = \1', deps_block) +if deps_block == deps_block2: + raise SystemExit("Did not find bouquin-sqlcipher4 dependency to rewrite") + +p.write_text(txt[:m.start(1)] + deps_block2 + txt[m.end(1):], encoding="utf-8") +PY + +desktop-file-validate debian/bouquin.desktop + +%generate_buildrequires +%pyproject_buildrequires + +%build +%pyproject_wheel + +%install +%pyproject_install +%pyproject_save_files bouquin + +# Desktop integration (mirrors debian/bouquin.install) +install -Dpm 0644 debian/bouquin.desktop %{buildroot}%{_datadir}/applications/bouquin.desktop +install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg + +%files -f %{pyproject_files} +%license LICENSE +%doc README.md CHANGELOG.md +%{_bindir}/bouquin + +%{_datadir}/applications/bouquin.desktop +%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg + +%changelog +* Wed Dec 24 2025 Miguel Jacq - %{version}-%{release} +- Initial RPM packaging for Fedora 42 From 26c136900e3a70350e202b1f3a51b1ed1235a67f Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 24 Dec 2025 17:58:20 +1100 Subject: [PATCH 16/31] Add chown and rpmsign step to rpm build --- release.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/release.sh b/release.sh index d358e80..00e552a 100755 --- a/release.sh +++ b/release.sh @@ -73,6 +73,8 @@ done sudo apt-get -y install createrepo-c rpm docker build -f Dockerfile.rpmbuild -t bouquin-rpm:f42 --progress=plain . docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:f42 +sudo chown -R "${USER}" "$PWD/dist" + REPO_ROOT="${HOME}/git/repo_rpm" RPM_REPO="${REPO_ROOT}/rpm/x86_64" BUILD_OUTPUT="${HOME}/git/bouquin/dist" @@ -88,6 +90,10 @@ createrepo_c "$RPM_REPO" echo "==> Signing repomd.xml..." qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" +for file in `ls -1 "$PWD/dist/rpm"`; do + rpmsign --addsign "$PWD/dist/rpm/$file" +done + echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" From 48e18e04085d17a5d0742b265a0d2546fa753d8b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 24 Dec 2025 18:27:24 +1100 Subject: [PATCH 17/31] Sign rpms before createrepo_c --- release.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/release.sh b/release.sh index 00e552a..47fedb0 100755 --- a/release.sh +++ b/release.sh @@ -85,15 +85,15 @@ echo "==> Updating RPM repo..." mkdir -p "$RPM_REPO" cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" +for file in `ls -1 "$PWD/dist/rpm"`; do + rpmsign --addsign "$PWD/dist/rpm/$file" +done + createrepo_c "$RPM_REPO" echo "==> Signing repomd.xml..." qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" -for file in `ls -1 "$PWD/dist/rpm"`; do - rpmsign --addsign "$PWD/dist/rpm/$file" -done - echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" From 2eba0df85ade39319fd2d8c0baccc78a58f4db64 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 24 Dec 2025 18:55:55 +1100 Subject: [PATCH 18/31] copy the rpm after signing it, you idiot --- release.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/release.sh b/release.sh index 47fedb0..6165069 100755 --- a/release.sh +++ b/release.sh @@ -83,12 +83,13 @@ KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" echo "==> Updating RPM repo..." mkdir -p "$RPM_REPO" -cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" -for file in `ls -1 "$PWD/dist/rpm"`; do - rpmsign --addsign "$PWD/dist/rpm/$file" +for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do + rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file" done +cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" + createrepo_c "$RPM_REPO" echo "==> Signing repomd.xml..." From 9c7cb7ba2bb02fda220c4a52718ee349b05080a4 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 26 Dec 2025 09:03:20 +1100 Subject: [PATCH 19/31] Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. --- CHANGELOG.md | 4 + bouquin/main_window.py | 59 ++-- bouquin/markdown_editor.py | 579 +++++++++++++++++++++++++++++++++++-- bouquin/toolbar.py | 15 +- 4 files changed, 592 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d94ebeb..259d9ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.1 + + * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. + # 0.8.0 * Add .desktop file for Debian diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 9b812b4..0cebf24 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -28,7 +28,6 @@ from PySide6.QtGui import ( QGuiApplication, QKeySequence, QTextCursor, - QTextListFormat, ) from PySide6.QtWidgets import ( QApplication, @@ -1241,46 +1240,58 @@ class MainWindow(QMainWindow): self._toolbar_bound = True def _sync_toolbar(self): - fmt = self.editor.currentCharFormat() + """ + Keep the toolbar "sticky" by reflecting the markdown state at the current caret/selection. + """ c = self.editor.textCursor() + line = c.block().text() + + # Inline styles (markdown-aware) + bold_on = bool(getattr(self.editor, "is_markdown_bold_active", lambda: False)()) + italic_on = bool( + getattr(self.editor, "is_markdown_italic_active", lambda: False)() + ) + strike_on = bool( + getattr(self.editor, "is_markdown_strike_active", lambda: False)() + ) # Block signals so setChecked() doesn't re-trigger actions QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actStrike) - self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) - self.toolBar.actItalic.setChecked(fmt.fontItalic()) - self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) + self.toolBar.actBold.setChecked(bold_on) + self.toolBar.actItalic.setChecked(italic_on) + self.toolBar.actStrike.setChecked(strike_on) - # Headings: decide which to check by current point size - def _approx(a, b, eps=0.5): # small float tolerance - return abs(float(a) - float(b)) <= eps - - cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() - - bH1 = _approx(cur_size, 24) - bH2 = _approx(cur_size, 18) - bH3 = _approx(cur_size, 14) + # Headings: infer from leading markdown markers + heading_level = 0 + m = re.match(r"^\s*(#{1,3})\s+", line) + if m: + heading_level = len(m.group(1)) QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH3) QSignalBlocker(self.toolBar.actNormal) - self.toolBar.actH1.setChecked(bH1) - self.toolBar.actH2.setChecked(bH2) - self.toolBar.actH3.setChecked(bH3) - self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) + self.toolBar.actH1.setChecked(heading_level == 1) + self.toolBar.actH2.setChecked(heading_level == 2) + self.toolBar.actH3.setChecked(heading_level == 3) + self.toolBar.actNormal.setChecked(heading_level == 0) + + # Lists: infer from leading markers on the current line + bullets_on = bool(re.match(r"^\s*(?:•|-|\*)\s+", line)) + numbers_on = bool(re.match(r"^\s*\d+\.\s+", line)) + checkboxes_on = bool(re.match(r"^\s*[☐☑]\s+", line)) - # Lists - lst = c.currentList() - bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc - numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal QSignalBlocker(self.toolBar.actBullets) QSignalBlocker(self.toolBar.actNumbers) - self.toolBar.actBullets.setChecked(bool(bullets_on)) - self.toolBar.actNumbers.setChecked(bool(numbers_on)) + QSignalBlocker(self.toolBar.actCheckboxes) + + self.toolBar.actBullets.setChecked(bullets_on) + self.toolBar.actNumbers.setChecked(numbers_on) + self.toolBar.actCheckboxes.setChecked(checkboxes_on) def _change_font_size(self, delta: int) -> None: """Change font size for all editor tabs and save the setting.""" diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 849f515..b1a6d66 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -46,7 +46,7 @@ class MarkdownEditor(QTextEdit): _COLLAPSE_LABEL_COLLAPSE = "collapse" _COLLAPSE_LABEL_EXPAND = "expand" _COLLAPSE_END_MARKER = "" - # Accept either "collapse" or "expand" in the header text (older files used only "collapse") + # Accept either "collapse" or "expand" in the header text _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") _COLLAPSE_END_RE = re.compile(r"^([ \t]*)\s*$") @@ -114,6 +114,9 @@ class MarkdownEditor(QTextEdit): # Track if we're currently updating text programmatically self._updating = False + # Track pending inline marker insertion (e.g. Italic with no selection) + self._pending_inline_marker: str | None = None + # Help avoid double-click selecting of checkbox self._suppress_next_checkbox_double_click = False @@ -928,6 +931,69 @@ class MarkdownEditor(QTextEdit): return None + def _maybe_skip_over_marker_run(self, key: Qt.Key) -> bool: + """Skip over common markdown marker runs when navigating with Left/Right. + + This prevents the caret from landing *inside* runs like '**', '***', '__', '___' or '~~', + which can cause temporary toolbar-state flicker and makes navigation feel like it takes + "two presses" to get past closing markers. + + Hold any modifier key (Shift/Ctrl/Alt/Meta) to disable this behavior. + """ + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + doc_max = self._doc_max_pos() + + # Right: run starts at the caret + if key == Qt.Key.Key_Right: + if p >= doc_max: + return False + ch = self._text_range(p, p + 1) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p + run < doc_max and self._text_range(p + run, p + run + 1) == ch: + run += 1 + + # Only skip multi-char runs (bold/strong/emphasis runs or strike) + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + + # Left: run ends at the caret + if key == Qt.Key.Key_Left: + if p <= 0: + return False + ch = self._text_range(p - 1, p) + if ch not in ("*", "_", "~"): + return False + + run = 0 + while p - 1 - run >= 0 and self._text_range(p - 1 - run, p - run) == ch: + run += 1 + + if ch in ("*", "_") and run >= 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, run) + self.setTextCursor(c) + return True + if ch == "~" and run == 2: + c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 2) + self.setTextCursor(c) + return True + + return False + def keyPressEvent(self, event): """Handle special key events for markdown editing.""" c = self.textCursor() @@ -936,7 +1002,6 @@ class MarkdownEditor(QTextEdit): in_code = self._is_inside_code_block(block) is_fence_line = block.text().strip().startswith("```") - # --- NEW: 3rd backtick shortcut → open code block dialog --- # Only when we're *not* already in a code block or on a fence line. if event.text() == "`" and not (in_code or is_fence_line): line = block.text() @@ -1002,6 +1067,14 @@ class MarkdownEditor(QTextEdit): super().keyPressEvent(event) return + if ( + event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right) + and event.modifiers() == Qt.KeyboardModifier.NoModifier + and not self.textCursor().hasSelection() + ): + if self._maybe_skip_over_marker_run(event.key()): + return + # --- Step out of a code block with Down at EOF --- if event.key() == Qt.Key.Key_Down: c = self.textCursor() @@ -1509,19 +1582,389 @@ class MarkdownEditor(QTextEdit): # ------------------------ Toolbar action handlers ------------------------ + # ------------------------ Inline markdown helpers ------------------------ + + def _doc_max_pos(self) -> int: + # QTextDocument includes a trailing null character; cursor positions stop before it. + doc = self.document() + return max(0, doc.characterCount() - 1) + + def _text_range(self, start: int, end: int) -> str: + """Return document text between [start, end) using QTextCursor indexing.""" + doc_max = self._doc_max_pos() + start = max(0, min(start, doc_max)) + end = max(0, min(end, doc_max)) + if end < start: + start, end = end, start + tc = QTextCursor(self.document()) + tc.setPosition(start) + tc.setPosition(end, QTextCursor.KeepAnchor) + return tc.selectedText() + + def _selection_wrapped_by( + self, + markers: tuple[str, ...], + *, + require_singletons: bool = False, + ) -> str | None: + """ + If the current selection is wrapped by any marker in `markers`, return the marker. + + Supports both cases: + 1) the selection itself includes the markers, e.g. "**bold**" + 2) the selection is the inner text, with markers immediately adjacent in the doc. + """ + c = self.textCursor() + if not c.hasSelection(): + return None + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + doc_max = self._doc_max_pos() + + # Case 1: selection includes markers + for m in markers: + lm = len(m) + if len(sel) >= 2 * lm and sel.startswith(m) and sel.endswith(m): + return m + + # Case 2: markers adjacent to selection + for m in markers: + lm = len(m) + if start < lm or end + lm > doc_max: + continue + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before != m or after != m: + continue + + if require_singletons and lm == 1: + # Ensure the single marker isn't part of a double/triple (e.g. "**" or "__") + ch = m + left_marker_pos = start - 1 + right_marker_pos = end + + if ( + left_marker_pos - 1 >= 0 + and self._text_range(left_marker_pos - 1, left_marker_pos) == ch + ): + continue + if ( + right_marker_pos + 1 <= doc_max + and self._text_range(right_marker_pos + 1, right_marker_pos + 2) + == ch + ): + continue + + return m + + return None + + def _caret_between_markers( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is exactly between an opening and closing marker (e.g. **|**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p < lm or p + lm > doc_max: + return False + + before = self._text_range(p - lm, p) + after = self._text_range(p, p + lm) + if before != marker or after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if either side is adjacent to the same char (part of "**", "__", "***", etc.) + ch = marker + if p - 2 >= 0 and self._text_range(p - 2, p - 1) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _caret_before_marker( + self, marker: str, *, require_singletons: bool = False + ) -> bool: + """True if the caret is immediately before `marker` (e.g. |**).""" + c = self.textCursor() + if c.hasSelection(): + return False + + p = c.position() + lm = len(marker) + doc_max = self._doc_max_pos() + if p + lm > doc_max: + return False + + after = self._text_range(p, p + lm) + if after != marker: + return False + + if require_singletons and lm == 1: + # Disallow if it's part of a run like "**" or "___". + ch = marker + if p - 1 >= 0 and self._text_range(p - 1, p) == ch: + return False + if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch: + return False + + return True + + def _unwrap_selection( + self, marker: str, *, replacement_marker: str | None = None + ) -> bool: + """ + Remove `marker` wrapping from the selection. + If replacement_marker is provided, replace marker with that (e.g. ***text*** -> *text*). + """ + c = self.textCursor() + if not c.hasSelection(): + return False + + sel = c.selectedText() + start = c.selectionStart() + end = c.selectionEnd() + lm = len(marker) + doc_max = self._doc_max_pos() + + def _select_inner( + edit_cursor: QTextCursor, inner_start: int, inner_len: int + ) -> None: + edit_cursor.setPosition(inner_start) + edit_cursor.setPosition(inner_start + inner_len, QTextCursor.KeepAnchor) + self.setTextCursor(edit_cursor) + + # Case 1: selection includes markers + if len(sel) >= 2 * lm and sel.startswith(marker) and sel.endswith(marker): + inner = sel[lm:-lm] + new_text = ( + f"{replacement_marker}{inner}{replacement_marker}" + if replacement_marker is not None + else inner + ) + c.beginEditBlock() + c.insertText(new_text) + c.endEditBlock() + + # Re-select the inner content (not the markers) + inner_start = c.position() - len(new_text) + if replacement_marker is not None: + inner_start += len(replacement_marker) + _select_inner(c, inner_start, len(inner)) + return True + + # Case 2: marker is adjacent to selection + if start >= lm and end + lm <= doc_max: + before = self._text_range(start - lm, start) + after = self._text_range(end, end + lm) + if before == marker and after == marker: + new_text = ( + f"{replacement_marker}{sel}{replacement_marker}" + if replacement_marker is not None + else sel + ) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start - lm) + edit.setPosition(end + lm, QTextCursor.KeepAnchor) + edit.insertText(new_text) + edit.endEditBlock() + + inner_start = (start - lm) + ( + len(replacement_marker) if replacement_marker else 0 + ) + _select_inner(edit, inner_start, len(sel)) + return True + + return False + + def _wrap_selection(self, marker: str) -> None: + """Wrap the current selection with `marker` and keep the content selected.""" + c = self.textCursor() + if not c.hasSelection(): + return + sel = c.selectedText() + start = c.selectionStart() + lm = len(marker) + + c.beginEditBlock() + c.insertText(f"{marker}{sel}{marker}") + c.endEditBlock() + + # Re-select the original content + edit = QTextCursor(self.document()) + edit.setPosition(start + lm) + edit.setPosition(start + lm + len(sel), QTextCursor.KeepAnchor) + self.setTextCursor(edit) + + def _pos_inside_inline_span( + self, + patterns: list[tuple[re.Pattern, int]], + start_in_block: int, + end_in_block: int, + ) -> bool: + """True if [start_in_block, end_in_block] lies within the content region of any pattern match.""" + block_text = self.textCursor().block().text() + for pat, mlen in patterns: + for m in pat.finditer(block_text): + s, e = m.span() + cs, ce = s + mlen, e - mlen + if cs <= start_in_block and end_in_block <= ce: + return True + return False + + def is_markdown_bold_active(self) -> bool: + c = self.textCursor() + bold_markers = ("***", "___", "**", "__") + + if c.hasSelection(): + if self._selection_wrapped_by(bold_markers) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + italic_markers = ("*", "_", "***", "___") + + if c.hasSelection(): + if ( + self._selection_wrapped_by(italic_markers, require_singletons=True) + is not None + ): + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [ + (re.compile(r"(? bool: + c = self.textCursor() + if c.hasSelection(): + if self._selection_wrapped_by(("~~",)) is not None: + return True + block = c.block() + start_in_block = c.selectionStart() - block.position() + end_in_block = c.selectionEnd() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, start_in_block, end_in_block) + + if self._caret_between_markers("~~"): + return True + block = c.block() + pos_in_block = c.position() - block.position() + patterns = [(re.compile(r"~~(.+?)~~"), 2)] + return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block) + + # ------------------------ Toolbar action handlers ------------------------ + def apply_weight(self): - """Toggle bold formatting.""" + """Toggle bold formatting (markdown ** / __, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - # Check if already bold - if selected.startswith("**") and selected.endswith("**"): - # Remove bold - new_text = selected[2:-2] - else: - # Add bold - new_text = f"**{selected}**" - cursor.insertText(new_text) + # If bold+italic, toggling bold should leave italic: ***text*** -> *text* + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "*" if m == "***" else "_" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + # Normal bold: **text** / __text__ + m = self._selection_wrapped_by(("**", "__")) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + # Not bold: wrap selection with ** + self._wrap_selection("**") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (**|**), remove them. + # - If we're inside bold and sitting right before the closing marker (**text|**), + # jump the caret *past* the marker (end-bold) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("**") or self._caret_between_markers("__"): + marker = "**" if self._caret_between_markers("**") else "__" + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + elif self.is_markdown_bold_active() and ( + self._caret_before_marker("**") or self._caret_before_marker("__") + ): + marker = "**" if self._caret_before_marker("**") else "__" + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: # No selection - just insert markers cursor.insertText("****") @@ -1529,44 +1972,120 @@ class MarkdownEditor(QTextEdit): QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_italic(self): - """Toggle italic formatting.""" + """Toggle italic formatting (markdown * / _, and *** / ___).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if ( - selected.startswith("*") - and selected.endswith("*") - and not selected.startswith("**") - ): - new_text = selected[1:-1] - else: - new_text = f"*{selected}*" - cursor.insertText(new_text) + # If bold+italic, toggling italic should leave bold: ***text*** -> **text** + m = self._selection_wrapped_by(("***", "___")) + if m is not None: + repl = "**" if m == "***" else "__" + if self._unwrap_selection(m, replacement_marker=repl): + self.setFocus() + return + + m = self._selection_wrapped_by(("*", "_"), require_singletons=True) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + + self._wrap_selection("*") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (*|*), remove them. + # - If we're inside italic and sitting right before the closing marker (*text|*), + # jump the caret past the marker (end-italic) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers( + "*", require_singletons=True + ) or self._caret_between_markers("_", require_singletons=True): + marker = ( + "*" + if self._caret_between_markers("*", require_singletons=True) + else "_" + ) + p = cursor.position() + lm = len(marker) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - lm) + edit.setPosition(p + lm, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - lm) + self.setTextCursor(edit) + self._pending_inline_marker = None + elif self.is_markdown_italic_active() and ( + self._caret_before_marker("*", require_singletons=True) + or self._caret_before_marker("_", require_singletons=True) + ): + marker = ( + "*" if self._caret_before_marker("*", require_singletons=True) else "_" + ) + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + len(marker), + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("**") cursor.movePosition( QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1 ) self.setTextCursor(cursor) + self._pending_inline_marker = "*" # Return focus to editor self.setFocus() def apply_strikethrough(self): - """Toggle strikethrough formatting.""" + """Toggle strikethrough formatting (markdown ~~).""" cursor = self.textCursor() + if cursor.hasSelection(): - selected = cursor.selectedText() - if selected.startswith("~~") and selected.endswith("~~"): - new_text = selected[2:-2] - else: - new_text = f"~~{selected}~~" - cursor.insertText(new_text) + m = self._selection_wrapped_by(("~~",)) + if m is not None: + if self._unwrap_selection(m): + self.setFocus() + return + self._wrap_selection("~~") + self.setFocus() + return + + # No selection: + # - If we're between an empty pair (~~|~~), remove them. + # - If we're inside strike and sitting right before the closing marker (~~text|~~), + # jump the caret past the marker (end-strike) instead of inserting more. + # - Otherwise, insert a new empty pair and place the caret between. + if self._caret_between_markers("~~"): + p = cursor.position() + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(p - 2) + edit.setPosition(p + 2, QTextCursor.KeepAnchor) + edit.insertText("") + edit.endEditBlock() + edit.setPosition(p - 2) + self.setTextCursor(edit) + elif self.is_markdown_strike_active() and self._caret_before_marker("~~"): + cursor.movePosition( + QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.MoveAnchor, + 2, + ) + self.setTextCursor(cursor) + self._pending_inline_marker = None else: cursor.insertText("~~~~") cursor.movePosition( diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 92383e6..8e8c4bf 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -98,6 +98,7 @@ class ToolBar(QToolBar): self.actNumbers.triggered.connect(self.numbersRequested) self.actCheckboxes = QAction("☑", self) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) + self.actCheckboxes.setCheckable(True) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images @@ -126,22 +127,14 @@ class ToolBar(QToolBar): self.actDocuments = QAction("📁", self) self.actDocuments.setToolTip(strings._("toolbar_documents")) self.actDocuments.triggered.connect(self.documentsRequested) - - # Set exclusive buttons in QActionGroups + # Headings are mutually exclusive (like radio buttons) self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) - for a in ( - self.actBold, - self.actItalic, - self.actStrike, - self.actH1, - self.actH2, - self.actH3, - self.actNormal, - ): + for a in (self.actH1, self.actH2, self.actH3, self.actNormal): a.setCheckable(True) a.setActionGroup(self.grpHeadings) + # List types are mutually exclusive self.grpLists = QActionGroup(self) self.grpLists.setExclusive(True) for a in (self.actBullets, self.actNumbers, self.actCheckboxes): From 7e47cef602ff592227f5aa634a79ea1f88bac6c2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 26 Dec 2025 17:06:45 +1100 Subject: [PATCH 20/31] Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day. --- CHANGELOG.md | 1 + bouquin/main_window.py | 213 +++++++++++++++++++++++++++------- tests/test_markdown_editor.py | 10 +- 3 files changed, 179 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 259d9ca..27bd1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 0.8.1 * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. + * Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day. # 0.8.0 diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 0cebf24..2d08863 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -870,6 +870,11 @@ class MainWindow(QMainWindow): into the rollover target date (today, or next Monday if today is a weekend). + In addition to moving the unchecked checkbox *line* itself, this also + moves any subsequent lines that belong to that unchecked item, up to + (but not including) the next *checked* checkbox line. This allows + code fences, collapsed blocks, and notes under a todo to travel with it. + Returns True if any items were moved, False otherwise. """ if not getattr(self.cfg, "move_todos", False): @@ -884,7 +889,9 @@ class MainWindow(QMainWindow): # Regexes for markdown headings and checkboxes heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$") - unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+") + unchecked_re = re.compile(r"^(\s*)-\s*\[[\s☐]\]\s+(.*)$") + checked_re = re.compile(r"^(\s*)-\s*\[[xX☑]\]\s+(.*)$") + fence_re = re.compile(r"^\s*(`{3,}|~{3,})") def _normalize_heading(text: str) -> str: """ @@ -895,13 +902,47 @@ class MainWindow(QMainWindow): text = re.sub(r"\s+#+\s*$", "", text) return text.strip() - def _insert_todos_under_heading( + def _update_fence_state( + line: str, in_fence: bool, fence_marker: str | None + ) -> tuple[bool, str | None]: + """ + Track fenced code blocks (``` / ~~~). We ignore checkbox markers inside + fences so we don't accidentally split/move based on "- [x]" that appears + in code. + """ + m = fence_re.match(line) + if not m: + return in_fence, fence_marker + + marker = m.group(1) + if not in_fence: + return True, marker + + # Close only when we see a fence of the same char and >= length + if ( + fence_marker + and marker[0] == fence_marker[0] + and len(marker) >= len(fence_marker) + ): + return False, None + + return in_fence, fence_marker + + def _is_list_item(line: str) -> bool: + s = line.lstrip() + return bool( + re.match(r"^([-*+]\s+|\d+\.\s+)", s) + or unchecked_re.match(line) + or checked_re.match(line) + ) + + def _insert_blocks_under_heading( target_lines: list[str], heading_level: int, heading_text: str, - todos: list[str], + blocks: list[list[str]], ) -> list[str]: - """Ensure a heading exists and append todos to the end of its section.""" + """Ensure a heading exists and append blocks to the end of its section.""" normalized = _normalize_heading(heading_text) # 1) Find existing heading with same text (any level) @@ -941,15 +982,33 @@ class MainWindow(QMainWindow): ): insert_at -= 1 - for todo in todos: - target_lines.insert(insert_at, todo) - insert_at += 1 + # Insert blocks (preserve internal blank lines) + for block in blocks: + if not block: + continue + + # Avoid gluing a paragraph to the new block unless both look like list items + if ( + insert_at > start_idx + 1 + and target_lines[insert_at - 1].strip() != "" + and block[0].strip() != "" + and not ( + _is_list_item(target_lines[insert_at - 1]) + and _is_list_item(block[0]) + ) + ): + target_lines.insert(insert_at, "") + insert_at += 1 + + for line in block: + target_lines.insert(insert_at, line) + insert_at += 1 return target_lines - # Collect moved todos as (heading_info, item_text) + # Collect moved blocks as (heading_info, block_lines) # heading_info is either None or (level, heading_text) - moved_items: list[tuple[tuple[int, str] | None, str]] = [] + moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = [] any_moved = False # Look back N days (yesterday = 1, up to `days_back`) @@ -965,26 +1024,81 @@ class MainWindow(QMainWindow): moved_from_this_day = False current_heading: tuple[int, str] | None = None - for line in lines: - # Track the last seen heading (# / ## / ###) - m_head = heading_re.match(line) - if m_head: - level = len(m_head.group(1)) - heading_text = _normalize_heading(m_head.group(2)) - if level <= 3: - current_heading = (level, heading_text) - # Keep headings in the original day - remaining_lines.append(line) - continue + in_fence = False + fence_marker: str | None = None - # Unchecked markdown checkboxes: "- [ ] " or "- [☐] " - if unchecked_re.match(line): - item_text = unchecked_re.sub("", line) - moved_items.append((current_heading, item_text)) - moved_from_this_day = True - any_moved = True - else: - remaining_lines.append(line) + i = 0 + while i < len(lines): + line = lines[i] + + # If we're not in a fenced code block, we can interpret headings/checkboxes + if not in_fence: + # Track the last seen heading (# / ## / ###) + m_head = heading_re.match(line) + if m_head: + level = len(m_head.group(1)) + heading_text = _normalize_heading(m_head.group(2)) + if level <= 3: + current_heading = (level, heading_text) + # Keep headings in the original day (only headings ABOVE a moved block are "carried") + remaining_lines.append(line) + in_fence, fence_marker = _update_fence_state( + line, in_fence, fence_marker + ) + i += 1 + continue + + # Start of an unchecked checkbox block + m_unchecked = unchecked_re.match(line) + if m_unchecked: + indent = m_unchecked.group(1) or "" + item_text = m_unchecked.group(2) + block: list[str] = [f"{indent}- [ ] {item_text}"] + + i += 1 + # Consume subsequent lines until the next *checked* checkbox + # (ignoring any "- [x]" that appear inside fenced code blocks) + block_in_fence = in_fence + block_fence_marker = fence_marker + + while i < len(lines): + nxt = lines[i] + + # If we're not inside a fence, a checked checkbox ends the block + if not block_in_fence and checked_re.match(nxt): + break + + # Normalize any unchecked checkbox lines inside the block + m_inner_unchecked = ( + unchecked_re.match(nxt) if not block_in_fence else None + ) + if m_inner_unchecked: + inner_indent = m_inner_unchecked.group(1) or "" + inner_text = m_inner_unchecked.group(2) + block.append(f"{inner_indent}- [ ] {inner_text}") + else: + block.append(nxt) + + # Update fence state after consuming the line + block_in_fence, block_fence_marker = _update_fence_state( + nxt, block_in_fence, block_fence_marker + ) + i += 1 + + # Carry the last heading *above* the unchecked checkbox + moved_blocks.append((current_heading, block)) + moved_from_this_day = True + any_moved = True + + # We consumed the block; keep scanning from the checked checkbox (or EOF) + continue + + # Default: keep the line on the original day + remaining_lines.append(line) + in_fence, fence_marker = _update_fence_state( + line, in_fence, fence_marker + ) + i += 1 if moved_from_this_day: modified_text = "\n".join(remaining_lines) @@ -998,33 +1112,46 @@ class MainWindow(QMainWindow): if not any_moved: return False - # --- Merge all moved items into the *target* date --- + # --- Merge all moved blocks into the *target* date --- target_text = self.db.get_entry(target_iso) or "" target_lines = target_text.split("\n") if target_text else [] - by_heading: dict[tuple[int, str], list[str]] = {} - plain_items: list[str] = [] + by_heading: dict[tuple[int, str], list[list[str]]] = {} + plain_blocks: list[list[str]] = [] - for heading_info, item_text in moved_items: - todo_line = f"- [ ] {item_text}" + for heading_info, block in moved_blocks: if heading_info is None: - # No heading above this checkbox in the source: behave as before - plain_items.append(todo_line) + plain_blocks.append(block) else: - by_heading.setdefault(heading_info, []).append(todo_line) + by_heading.setdefault(heading_info, []).append(block) - # First insert all items that have headings - for (level, heading_text), todos in by_heading.items(): - target_lines = _insert_todos_under_heading( - target_lines, level, heading_text, todos + # First insert all blocks that have headings + for (level, heading_text), blocks in by_heading.items(): + target_lines = _insert_blocks_under_heading( + target_lines, level, heading_text, blocks ) - # Then append all items without headings at the end, like before - if plain_items: + # Then append all blocks without headings at the end, like before + if plain_blocks: if target_lines and target_lines[-1].strip(): target_lines.append("") # one blank line before the "unsectioned" todos - target_lines.extend(plain_items) + first = True + for block in plain_blocks: + if not block: + continue + if ( + not first + and target_lines + and target_lines[-1].strip() != "" + and block[0].strip() != "" + and not ( + _is_list_item(target_lines[-1]) and _is_list_item(block[0]) + ) + ): + target_lines.append("") + target_lines.extend(block) + first = False new_target_text = "\n".join(target_lines) if not new_target_text.endswith("\n"): diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index a36a09e..9dac5d6 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,6 +1,7 @@ import base64 import pytest +import re from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.theme import Theme, ThemeConfig, ThemeManager @@ -72,8 +73,13 @@ def test_apply_styles_and_headings(editor, qtbot): editor.apply_italic() editor.apply_strikethrough() editor.apply_heading(24) - md = editor.to_markdown() - assert "**" in md and "*~~~~*" in md + md = editor.to_markdown().strip() + + assert md.startswith("# ") + assert "~~hello world~~" in md + assert re.search( + r"\*{2,3}~~hello world~~\*{2,3}", md + ) # bold or bold+italic wrapping strike def test_toggle_lists_and_checkboxes(editor): From dce124e083e00ec6b9dc28d482160d3d6638266b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 26 Dec 2025 18:01:56 +1100 Subject: [PATCH 21/31] prep for 0.8.1 --- debian/changelog | 7 +++++++ rpm/bouquin.spec | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 0216393..036fb4e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +bouquin (0.8.1) unstable; urgency=medium + + * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. + * Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day. + + -- Miguel Jacq Tue, 26 Dec 2025 18:00:00 +1100 + bouquin (0.8.0) unstable; urgency=medium * Add .desktop file for Debian diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec index 341d80d..f7d3c37 100644 --- a/rpm/bouquin.spec +++ b/rpm/bouquin.spec @@ -4,7 +4,7 @@ # provides the Python distribution/module as "sqlcipher4". To keep Fedora's # auto-generated python3dist() Requires correct, we rewrite the dependency key in # pyproject.toml at build time. -%global upstream_version 0.8.0 +%global upstream_version 0.8.1 Name: bouquin Version: %{upstream_version} @@ -82,5 +82,8 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo %{_datadir}/icons/hicolor/scalable/apps/bouquin.svg %changelog +* Fri Dec 26 2025 Miguel Jacq - %{version}-%{release} +- Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. +- Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day. * Wed Dec 24 2025 Miguel Jacq - %{version}-%{release} - Initial RPM packaging for Fedora 42 From 827565838f9dd03b9994fe60c8e93b9adc0e5ba7 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 26 Dec 2025 18:02:04 +1100 Subject: [PATCH 22/31] Bump to 0.8.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9adca9a..380e67b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.8.0" +version = "0.8.1" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" From 04f67a786f6da93bdb44fc24e88805cca7f5ff8d Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 31 Dec 2025 16:09:16 +1100 Subject: [PATCH 23/31] Add ability to delete an invoice via Manage Invoices dialog --- CHANGELOG.md | 4 +++ bouquin/db.py | 12 ++++++++ bouquin/invoices.py | 66 +++++++++++++++++++++++++++++++++++++++++ bouquin/locales/en.json | 3 +- bouquin/locales/fr.json | 3 +- debian/changelog | 6 ++++ rpm/bouquin.spec | 4 ++- 7 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bd1a3..808fb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.2 + + * Add ability to delete an invoice via 'Manage Invoices' dialog + # 0.8.1 * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. diff --git a/bouquin/db.py b/bouquin/db.py index d1c6a69..157aae8 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -2392,6 +2392,18 @@ class DBManager: (document_id, invoice_id), ) + def delete_invoice(self, invoice_id: int) -> None: + """Delete an invoice. + + Related invoice line items and invoice ↔ time log links are removed via + ON DELETE CASCADE. + """ + with self.conn: + self.conn.execute( + "DELETE FROM invoices WHERE id = ?", + (invoice_id,), + ) + def time_logs_for_range( self, project_id: int, diff --git a/bouquin/invoices.py b/bouquin/invoices.py index a0b50cb..fde6a92 100644 --- a/bouquin/invoices.py +++ b/bouquin/invoices.py @@ -1065,6 +1065,10 @@ class InvoicesDialog(QDialog): btn_row = QHBoxLayout() btn_row.addStretch(1) + delete_btn = QPushButton(strings._("delete")) + delete_btn.clicked.connect(self._on_delete_clicked) + btn_row.addWidget(delete_btn) + close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) btn_row.addWidget(close_btn) @@ -1073,6 +1077,68 @@ class InvoicesDialog(QDialog): self._reload_invoices() + # ----------------------------------------------------------------- deletion + + def _on_delete_clicked(self) -> None: + """Delete the currently selected invoice.""" + row = self.table.currentRow() + if row < 0: + sel = self.table.selectionModel().selectedRows() + if sel: + row = sel[0].row() + if row < 0: + QMessageBox.information( + self, + strings._("delete"), + strings._("invoice_required"), + ) + return + + base_item = self.table.item(row, self.COL_NUMBER) + if base_item is None: + return + + inv_id = base_item.data(Qt.ItemDataRole.UserRole) + if not inv_id: + return + + invoice_number = (base_item.text() or "").strip() or "?" + proj_item = self.table.item(row, self.COL_PROJECT) + project_name = (proj_item.text() if proj_item is not None else "").strip() + + label = strings._("delete") + prompt = ( + f"{label} '{invoice_number}'" + + (f" ({project_name})" if project_name else "") + + "?" + ) + + resp = QMessageBox.question( + self, + label, + prompt, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if resp != QMessageBox.StandardButton.Yes: + return + + # Remove any automatically created due-date reminder. + if self.cfg.reminders: + self._remove_invoice_due_reminder(row, int(inv_id)) + + try: + self._db.delete_invoice(int(inv_id)) + except Exception as e: + QMessageBox.warning( + self, + strings._("error"), + f"Failed to delete invoice: {e}", + ) + return + + self._reload_invoices() + # ------------------------------------------------------------------ helpers def _reload_projects(self) -> None: diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 26a4d5c..f1f86dd 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -436,5 +436,6 @@ "invoice_invalid_date_format": "Invalid date format", "invoice_invalid_tax_rate": "The tax rate is invalid", "invoice_no_items": "There are no items in the invoice", - "invoice_number_required": "An invoice number is required" + "invoice_number_required": "An invoice number is required", + "invoice_required": "Please select a specific invoice before trying to delete an invoice." } diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index d82890d..87a6a16 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -432,5 +432,6 @@ "invoice_invalid_date_format": "Format de date invalide", "invoice_invalid_tax_rate": "Le taux de TVA est invalide", "invoice_no_items": "La facture ne contient aucun article", - "invoice_number_required": "Un numéro de facture est requis" + "invoice_number_required": "Un numéro de facture est requis", + "invoice_required": "Veuillez sélectionner une facture spécifique avant d'essayer de supprimer la facture." } diff --git a/debian/changelog b/debian/changelog index 036fb4e..26075f4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +bouquin (0.8.2) unstable; urgency=medium + + * Add ability to delete an invoice via 'Manage Invoices' dialog + + -- Miguel Jacq Wed, 31 Dec 2025 16:00:00 +1100 + bouquin (0.8.1) unstable; urgency=medium * Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec index f7d3c37..2f8d442 100644 --- a/rpm/bouquin.spec +++ b/rpm/bouquin.spec @@ -4,7 +4,7 @@ # provides the Python distribution/module as "sqlcipher4". To keep Fedora's # auto-generated python3dist() Requires correct, we rewrite the dependency key in # pyproject.toml at build time. -%global upstream_version 0.8.1 +%global upstream_version 0.8.2 Name: bouquin Version: %{upstream_version} @@ -82,6 +82,8 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo %{_datadir}/icons/hicolor/scalable/apps/bouquin.svg %changelog +* Wed Dec 31 2025 Miguel Jacq - %{version}-%{release} +- Add ability to delete an invoice via 'Manage Invoices' dialog * Fri Dec 26 2025 Miguel Jacq - %{version}-%{release} - Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used. - Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day. From f8aab05cb74845569d85b31f4308e18a585b5f07 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 31 Dec 2025 16:09:29 +1100 Subject: [PATCH 24/31] Bump to 0.8.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 380e67b..868fb26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.8.1" +version = "0.8.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" From b192264dbf2b3eb7be20f87eec760958b5d160a8 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 31 Dec 2025 17:12:21 +1100 Subject: [PATCH 25/31] server migration --- release.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/release.sh b/release.sh index 6165069..14ecb4e 100755 --- a/release.sh +++ b/release.sh @@ -24,18 +24,18 @@ fi set +e sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml -git add pyproject.toml -git commit -m "Bump to ${VERSION}" -git push origin main +#git add pyproject.toml +#git commit -m "Bump to ${VERSION}" +#git push origin main set -e # Clean caches etc -filedust -y . +#filedust -y . # Publish to Pypi -poetry build -poetry publish +#poetry build +#poetry publish # Make AppImage sudo apt-get -y install libfuse-dev @@ -100,4 +100,4 @@ rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" echo "Done!" -ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt" +ssh lupin.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt" From 7bb2746a0fcdc67430c4353b045c11a3503da923 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 1 Jan 2026 17:09:37 +1100 Subject: [PATCH 26/31] Prep for supporting other fedora versions later --- release.sh | 55 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/release.sh b/release.sh index 14ecb4e..5ab4237 100755 --- a/release.sh +++ b/release.sh @@ -24,18 +24,18 @@ fi set +e sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml -#git add pyproject.toml -#git commit -m "Bump to ${VERSION}" -#git push origin main +git add pyproject.toml +git commit -m "Bump to ${VERSION}" +git push origin main set -e # Clean caches etc -#filedust -y . +filedust -y . # Publish to Pypi -#poetry build -#poetry publish +poetry build +poetry publish # Make AppImage sudo apt-get -y install libfuse-dev @@ -70,31 +70,42 @@ for dist in ${DISTS[@]}; do done # RPM -sudo apt-get -y install createrepo-c rpm -docker build -f Dockerfile.rpmbuild -t bouquin-rpm:f42 --progress=plain . -docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:f42 -sudo chown -R "${USER}" "$PWD/dist" - REPO_ROOT="${HOME}/git/repo_rpm" RPM_REPO="${REPO_ROOT}/rpm/x86_64" BUILD_OUTPUT="${HOME}/git/bouquin/dist" REMOTE="letessier.mig5.net:/opt/repo_rpm" KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" - -echo "==> Updating RPM repo..." mkdir -p "$RPM_REPO" -for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do - rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file" +sudo apt-get -y install createrepo-c rpm + +DISTS=( + fedora:42 +) + +for dist in ${DISTS[@]}; do + release=$(echo ${dist} | cut -d: -f2) + docker build -f Dockerfile.rpmbuild \ + -t bouquin-rpm:${release} \ + --progress=plain \ + --build-arg BASE_IMAGE=${dist} \ + . + + docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:${release} + sudo chown -R "${USER}" "$PWD/dist" + + for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do + rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file" + done + + cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" + + createrepo_c "$RPM_REPO" + + echo "==> Signing repomd.xml..." + qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" done -cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/" - -createrepo_c "$RPM_REPO" - -echo "==> Signing repomd.xml..." -qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" - echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" From 5f89c4286efd7487dfeca1270234bce6d5aa748a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 3 Jan 2026 12:11:49 +1100 Subject: [PATCH 27/31] Fix releasing for Fedora --- Dockerfile.rpmbuild | 4 +++- README.md | 2 +- release.sh | 20 ++++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild index 01201c1..9013b6e 100644 --- a/Dockerfile.rpmbuild +++ b/Dockerfile.rpmbuild @@ -42,11 +42,13 @@ SRC="${SRC:-/src}" WORKROOT="${WORKROOT:-/work}" OUT="${OUT:-/out}" DEPS_DIR="${DEPS_DIR:-/deps}" +VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)" +echo "Version ID is ${VERSION_ID}" # Install bouquin-sqlcipher4 from local rpm # Filter out .src.rpm and debug* subpackages if present. if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then - mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)') + mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)' | grep "${VERSION_ID}") if [ "${#rpms[@]}" -gt 0 ]; then echo "Installing dependency RPMs from ${DEPS_DIR}:" printf ' - %s\n' "${rpms[@]}" diff --git a/README.md b/README.md index 7d4e55f..c01a35e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF' [mig5] name=mig5 Repository -baseurl=https://rpm.mig5.net/rpm/$basearch +baseurl=https://rpm.mig5.net/rpm/$releasever/$basearch enabled=1 gpgcheck=1 repo_gpgcheck=1 diff --git a/release.sh b/release.sh index 5ab4237..af83196 100755 --- a/release.sh +++ b/release.sh @@ -69,15 +69,13 @@ for dist in ${DISTS[@]}; do reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" done -# RPM -REPO_ROOT="${HOME}/git/repo_rpm" -RPM_REPO="${REPO_ROOT}/rpm/x86_64" -BUILD_OUTPUT="${HOME}/git/bouquin/dist" -REMOTE="letessier.mig5.net:/opt/repo_rpm" -KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" -mkdir -p "$RPM_REPO" +# RPM sudo apt-get -y install createrepo-c rpm +BUILD_OUTPUT="${HOME}/git/bouquin/dist" +KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D" +REPO_ROOT="${HOME}/git/repo_rpm" +REMOTE="letessier.mig5.net:/opt/repo_rpm" DISTS=( fedora:42 @@ -85,7 +83,13 @@ DISTS=( for dist in ${DISTS[@]}; do release=$(echo ${dist} | cut -d: -f2) - docker build -f Dockerfile.rpmbuild \ + REPO_RELEASE_ROOT="${REPO_ROOT}/fc${release}" + RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64" + mkdir -p "$RPM_REPO" + + docker build \ + --no-cache \ + -f Dockerfile.rpmbuild \ -t bouquin-rpm:${release} \ --progress=plain \ --build-arg BASE_IMAGE=${dist} \ From dd1ae74b192ec8783145526b48bd88142fbc5f4f Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sat, 3 Jan 2026 12:50:36 +1100 Subject: [PATCH 28/31] remove 'fc' from release root --- README.md | 2 +- release.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c01a35e..30f7ce1 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF' [mig5] name=mig5 Repository -baseurl=https://rpm.mig5.net/rpm/$releasever/$basearch +baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch enabled=1 gpgcheck=1 repo_gpgcheck=1 diff --git a/release.sh b/release.sh index af83196..63e2723 100755 --- a/release.sh +++ b/release.sh @@ -83,7 +83,7 @@ DISTS=( for dist in ${DISTS[@]}; do release=$(echo ${dist} | cut -d: -f2) - REPO_RELEASE_ROOT="${REPO_ROOT}/fc${release}" + REPO_RELEASE_ROOT="${REPO_ROOT}/${release}" RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64" mkdir -p "$RPM_REPO" From 9f399c589d9db4e91a55fb27f103cd11aab7c4d1 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 9 Jan 2026 12:07:46 +1100 Subject: [PATCH 29/31] Update urllib3 dependency to resolve CVE-2026-21441 --- CHANGELOG.md | 4 ++++ poetry.lock | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808fb36..f5cb7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.3 (unreleased) + + * Update urllib3 dependency to resolve CVE-2026-21441 + # 0.8.2 * Add ability to delete an invoice via 'Manage Invoices' dialog diff --git a/poetry.lock b/poetry.lock index c320489..af05c5f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -622,13 +622,13 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] From 7f2c88f52b3575634e6bb52e7e9ca122225a8fa0 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 30 Jan 2026 16:49:45 +1100 Subject: [PATCH 30/31] Fix carrying over data to next day from over-capturing data belonging to next header section Other dependency updates --- CHANGELOG.md | 4 +- bouquin/main_window.py | 128 +++++++++++++++++- debian/changelog | 8 ++ poetry.lock | 297 +++++++++++++++++++++-------------------- pyproject.toml | 2 +- rpm/bouquin.spec | 6 +- 6 files changed, 290 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cb7bb..01266cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -# 0.8.3 (unreleased) +# 0.8.3 * Update urllib3 dependency to resolve CVE-2026-21441 + * Fix carrying over data to next day from over-capturing data belonging to next header section + * Other dependency updates # 0.8.2 diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 2d08863..2759272 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -871,9 +871,11 @@ class MainWindow(QMainWindow): is a weekend). In addition to moving the unchecked checkbox *line* itself, this also - moves any subsequent lines that belong to that unchecked item, up to - (but not including) the next *checked* checkbox line. This allows - code fences, collapsed blocks, and notes under a todo to travel with it. + moves any subsequent lines that belong to that unchecked item, stopping + at the next *checked* checkbox line **or** the next markdown heading. + + This allows code fences, collapsed blocks, and notes under a todo to + travel with it without accidentally pulling in the next section. Returns True if any items were moved, False otherwise. """ @@ -1006,6 +1008,110 @@ class MainWindow(QMainWindow): return target_lines + def _prune_empty_headings(src_lines: list[str]) -> list[str]: + """Remove markdown headings whose section became empty. + + The rollover logic removes unchecked todo *blocks* but intentionally keeps + headings on the source day so we can re-create the same section on the + target day. If a heading ends up with no remaining content (including + empty subheadings), we should remove it from the source day too. + + Headings inside fenced code blocks are ignored. + """ + + # Identify headings (outside fences) and their levels + heading_levels: dict[int, int] = {} + heading_indices: list[int] = [] + + in_f = False + f_mark: str | None = None + for idx, ln in enumerate(src_lines): + if not in_f: + m = heading_re.match(ln) + if m: + heading_indices.append(idx) + heading_levels[idx] = len(m.group(1)) + in_f, f_mark = _update_fence_state(ln, in_f, f_mark) + + if not heading_indices: + return src_lines + + # Compute each heading's section boundary: next heading with level <= current + boundary: dict[int, int] = {} + stack: list[int] = [] + for idx in heading_indices: + lvl = heading_levels[idx] + while stack and lvl <= heading_levels[stack[-1]]: + boundary[stack.pop()] = idx + stack.append(idx) + for idx in stack: + boundary[idx] = len(src_lines) + + # Build parent/children relationships based on heading levels + children: dict[int, list[int]] = {} + parent_stack: list[int] = [] + for idx in heading_indices: + lvl = heading_levels[idx] + while parent_stack and lvl <= heading_levels[parent_stack[-1]]: + parent_stack.pop() + if parent_stack: + children.setdefault(parent_stack[-1], []).append(idx) + parent_stack.append(idx) + + # Determine whether each heading has any non-heading, non-blank content in its span + has_body: dict[int, bool] = {} + for h_idx in heading_indices: + end = boundary[h_idx] + body = False + in_f = False + f_mark = None + for j in range(h_idx + 1, end): + ln = src_lines[j] + if not in_f: + if ln.strip() and not heading_re.match(ln): + body = True + break + in_f, f_mark = _update_fence_state(ln, in_f, f_mark) + has_body[h_idx] = body + + # Bottom-up: keep headings that have body content or any kept child headings + keep: dict[int, bool] = {} + for h_idx in reversed(heading_indices): + keep_child = any(keep.get(ch, False) for ch in children.get(h_idx, [])) + keep[h_idx] = has_body[h_idx] or keep_child + + remove_set = {idx for idx, k in keep.items() if not k} + if not remove_set: + return src_lines + + # Remove empty headings and any immediate blank lines following them + out: list[str] = [] + i = 0 + while i < len(src_lines): + if i in remove_set: + i += 1 + while i < len(src_lines) and src_lines[i].strip() == "": + i += 1 + continue + out.append(src_lines[i]) + i += 1 + + # Normalize excessive blank lines created by removals + cleaned: list[str] = [] + prev_blank = False + for ln in out: + blank = ln.strip() == "" + if blank and prev_blank: + continue + cleaned.append(ln) + prev_blank = blank + + while cleaned and cleaned[0].strip() == "": + cleaned.pop(0) + while cleaned and cleaned[-1].strip() == "": + cleaned.pop() + return cleaned + # Collect moved blocks as (heading_info, block_lines) # heading_info is either None or (level, heading_text) moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = [] @@ -1064,8 +1170,11 @@ class MainWindow(QMainWindow): while i < len(lines): nxt = lines[i] - # If we're not inside a fence, a checked checkbox ends the block - if not block_in_fence and checked_re.match(nxt): + # If we're not inside a fence, a checked checkbox ends the block, + # otherwise a new heading does as well. + if not block_in_fence and ( + checked_re.match(nxt) or heading_re.match(nxt) + ): break # Normalize any unchecked checkbox lines inside the block @@ -1101,6 +1210,7 @@ class MainWindow(QMainWindow): i += 1 if moved_from_this_day: + remaining_lines = _prune_empty_headings(remaining_lines) modified_text = "\n".join(remaining_lines) # Save the cleaned-up source day self.db.save_new_version( @@ -1115,7 +1225,13 @@ class MainWindow(QMainWindow): # --- Merge all moved blocks into the *target* date --- target_text = self.db.get_entry(target_iso) or "" - target_lines = target_text.split("\n") if target_text else [] + # Treat a whitespace-only target note as truly empty; otherwise we can + # end up appending the new heading *after* leading blank lines (e.g. if + # a newly-created empty day was previously saved as just "\n"). + if not target_text.strip(): + target_lines = [] + else: + target_lines = target_text.split("\n") by_heading: dict[tuple[int, str], list[list[str]]] = {} plain_blocks: list[list[str]] = [] diff --git a/debian/changelog b/debian/changelog index 26075f4..c49574f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +bouquin (0.8.3) unstable; urgency=medium + + * Update urllib3 dependency to resolve CVE-2026-21441 + * Fix carrying over data to next day from over-capturing data belonging to next header section + * Other dependency updates + + -- Miguel Jacq Fri, 30 Jan 2026 16:48:00 +1100 + bouquin (0.8.2) unstable; urgency=medium * Add ability to delete an invoice via 'Manage Invoices' dialog diff --git a/poetry.lock b/poetry.lock index af05c5f..0cf8448 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,13 +14,13 @@ files = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -158,103 +158,103 @@ files = [ [[package]] name = "coverage" -version = "7.13.0" +version = "7.13.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" files = [ - {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, - {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, - {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, - {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, - {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, - {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, - {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, - {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, - {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, - {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, - {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, - {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, - {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, - {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, - {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, - {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, - {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, - {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, - {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, - {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, - {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, - {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, - {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, - {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, + {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"}, + {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"}, + {file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"}, + {file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"}, + {file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"}, + {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"}, + {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"}, + {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"}, + {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"}, + {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"}, + {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"}, + {file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"}, + {file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"}, + {file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"}, + {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"}, + {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"}, + {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"}, + {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"}, + {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"}, + {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"}, + {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"}, + {file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"}, + {file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"}, + {file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"}, + {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"}, + {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"}, + {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"}, + {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"}, + {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"}, + {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"}, + {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"}, + {file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"}, + {file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"}, + {file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"}, + {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"}, + {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"}, + {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"}, + {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"}, + {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"}, + {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"}, + {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"}, + {file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"}, + {file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"}, + {file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"}, + {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"}, + {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"}, + {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"}, + {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"}, + {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"}, + {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"}, + {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"}, + {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"}, + {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"}, + {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"}, + {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"}, + {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"}, + {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"}, + {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"}, + {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"}, + {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"}, + {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"}, + {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"}, + {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"}, + {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"}, + {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"}, + {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"}, + {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"}, + {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"}, + {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"}, + {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"}, + {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"}, ] [package.dependencies] @@ -321,28 +321,28 @@ files = [ [[package]] name = "markdown" -version = "3.10" +version = "3.10.1" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.10" files = [ - {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, - {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, + {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"}, + {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"}, ] [package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] testing = ["coverage", "pyyaml"] [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] @@ -560,53 +560,58 @@ files = [ [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 868fb26..0eef382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.8.2" +version = "0.8.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec index 2f8d442..d50d461 100644 --- a/rpm/bouquin.spec +++ b/rpm/bouquin.spec @@ -4,7 +4,7 @@ # provides the Python distribution/module as "sqlcipher4". To keep Fedora's # auto-generated python3dist() Requires correct, we rewrite the dependency key in # pyproject.toml at build time. -%global upstream_version 0.8.2 +%global upstream_version 0.8.3 Name: bouquin Version: %{upstream_version} @@ -82,6 +82,10 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo %{_datadir}/icons/hicolor/scalable/apps/bouquin.svg %changelog +* Fri Jan 30 2026 Miguel Jacq - %{version}-%{release} +- Update urllib3 dependency to resolve CVE-2026-21441 +- Fix carrying over data to next day from over-capturing data belonging to next header section +- Other dependency updates * Wed Dec 31 2025 Miguel Jacq - %{version}-%{release} - Add ability to delete an invoice via 'Manage Invoices' dialog * Fri Dec 26 2025 Miguel Jacq - %{version}-%{release} From 5bb61273da38ba0554fa08676474a9d799a0167c Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 30 Jan 2026 17:03:38 +1100 Subject: [PATCH 31/31] Fix version change --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.sh b/release.sh index 63e2723..0d3945e 100755 --- a/release.sh +++ b/release.sh @@ -115,4 +115,4 @@ rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" echo "Done!" -ssh lupin.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt" +ssh lupin.mig5.net "echo ${VERSION} | tee /var/www/bouquin/version.txt"