Compare commits

..

No commits in common. "main" and "0.6.1" have entirely different histories.
main ... 0.6.1

71 changed files with 873 additions and 6220 deletions

View file

@ -35,16 +35,3 @@ jobs:
run: | run: |
./tests.sh ./tests.sh
# 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"

View file

@ -25,17 +25,3 @@ jobs:
pyflakes3 tests/* pyflakes3 tests/*
vulture vulture
bandit -s B110 -r bouquin/ bandit -s B110 -r bouquin/
# 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"

View file

@ -24,17 +24,3 @@ jobs:
- name: Run trivy - name: Run trivy
run: | run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
# 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"

3
.gitignore vendored
View file

@ -5,6 +5,3 @@ __pycache__
dist dist
.coverage .coverage
*.db *.db
*.pdf
*.csv
*.html

View file

@ -1,26 +0,0 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^bouquin/
args: ["-s", "B110"]

View file

@ -1,48 +1,3 @@
# 0.7.3
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
# 0.7.2
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
# 0.7.1
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
* Invoicing should not be enabled by default
* Fix Reminders to fire right on the minute after adding them during runtime
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
# 0.7.0
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
* Add 'Last week' to Time Report dialog range option
* Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
# 0.6.4
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
* Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
* Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
# 0.6.3
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
* Allow 'All Projects' for timesheet reports.
* Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
* Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
# 0.6.2
* Ensure that adding a document whilst on an older date page, uses that date as its upload date
* Add 'Created at' to time log table.
* Show total hours for the day in the time log table (not just in the widget in sidebar)
* Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
* Indent tabs by 4 spaces in code block editor dialog
# 0.6.1 # 0.6.1
* Consolidate some code related to opening documents using the Documents feature. * Consolidate some code related to opening documents using the Documents feature.

View file

@ -79,6 +79,8 @@ report from within the app, or optionally to check for new versions to upgrade t
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
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). 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).
### From PyPi/pip ### From PyPi/pip

View file

@ -3,17 +3,19 @@ from __future__ import annotations
import importlib.metadata import importlib.metadata
import requests import requests
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox,
QLabel,
QMessageBox,
QTextEdit,
QVBoxLayout, QVBoxLayout,
QLabel,
QTextEdit,
QDialogButtonBox,
QMessageBox,
) )
from . import strings from . import strings
BUG_REPORT_HOST = "https://nr.mig5.net" BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs" ROUTE = "forms/bouquin/bugs"

View file

@ -1,14 +1,15 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QRect, QSize, Qt from PySide6.QtCore import QSize, QRect, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QDialogButtonBox,
QLabel,
QPlainTextEdit,
QVBoxLayout, QVBoxLayout,
QPlainTextEdit,
QDialogButtonBox,
QComboBox,
QLabel,
QWidget, QWidget,
) )
@ -39,21 +40,9 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
self.cursorPositionChanged.connect(self._line_number_area.update) self.cursorPositionChanged.connect(self._line_number_area.update)
self._update_line_number_area_width() self._update_line_number_area_width()
self._update_tab_stop_width()
# ---- layout / sizing ------------------------------------------------- # ---- layout / sizing -------------------------------------------------
def setFont(self, font: QFont) -> None: # type: ignore[override]
"""Ensure tab width stays at 4 spaces when the font changes."""
super().setFont(font)
self._update_tab_stop_width()
def _update_tab_stop_width(self) -> None:
"""Set tab width to 4 spaces."""
metrics = QFontMetrics(self.font())
# Tab width = width of 4 space characters
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
def line_number_area_width(self) -> int: def line_number_area_width(self) -> int:
# Enough digits for large-ish code blocks. # Enough digits for large-ish code blocks.
digits = max(2, len(str(max(1, self.blockCount())))) digits = max(2, len(str(max(1, self.blockCount()))))

View file

@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Dict, Optional from typing import Optional, Dict
from PySide6.QtGui import QColor, QFont, QTextCharFormat from PySide6.QtGui import QColor, QTextCharFormat, QFont
class CodeHighlighter: class CodeHighlighter:

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@ and TagBrowserDialog).
from __future__ import annotations from __future__ import annotations
import tempfile
from pathlib import Path from pathlib import Path
import tempfile
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import QUrl from PySide6.QtCore import QUrl

View file

@ -5,32 +5,32 @@ from typing import Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog, QDialog,
QFileDialog, QVBoxLayout,
QFormLayout,
QFrame,
QHBoxLayout, QHBoxLayout,
QHeaderView, QFormLayout,
QComboBox,
QLineEdit, QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QStyle,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QToolButton, QAbstractItemView,
QVBoxLayout, QHeaderView,
QPushButton,
QFileDialog,
QMessageBox,
QWidget, QWidget,
QFrame,
QToolButton,
QListWidget,
QListWidgetItem,
QSizePolicy,
QStyle,
) )
from . import strings
from .db import DBManager, DocumentRow from .db import DBManager, DocumentRow
from .settings import load_db_config from .settings import load_db_config
from .time_log import TimeCodeManagerDialog from .time_log import TimeCodeManagerDialog
from . import strings
class TodaysDocumentsWidget(QFrame): class TodaysDocumentsWidget(QFrame):
@ -112,7 +112,7 @@ class TodaysDocumentsWidget(QFrame):
if project_name: if project_name:
extra_parts.append(project_name) extra_parts.append(project_name)
if extra_parts: if extra_parts:
label = f"{file_name} - " + " · ".join(extra_parts) label = f"{file_name} " + " · ".join(extra_parts)
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData( item.setData(
@ -151,7 +151,7 @@ class TodaysDocumentsWidget(QFrame):
def _open_documents_dialog(self) -> None: def _open_documents_dialog(self) -> None:
"""Open the full DocumentsDialog.""" """Open the full DocumentsDialog."""
dlg = DocumentsDialog(self._db, self, current_date=self._current_date) dlg = DocumentsDialog(self._db, self)
dlg.exec() dlg.exec()
# Refresh after any changes # Refresh after any changes
self.reload() self.reload()
@ -179,14 +179,12 @@ class DocumentsDialog(QDialog):
db: DBManager, db: DBManager,
parent: QWidget | None = None, parent: QWidget | None = None,
initial_project_id: Optional[int] = None, initial_project_id: Optional[int] = None,
current_date: Optional[str] = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.cfg = load_db_config() self.cfg = load_db_config()
self._reloading_docs = False self._reloading_docs = False
self._search_text: str = "" self._search_text: str = ""
self._current_date = current_date # Store the current date for document uploads
self.setWindowTitle(strings._("project_documents_title")) self.setWindowTitle(strings._("project_documents_title"))
self.resize(900, 450) self.resize(900, 450)
@ -384,9 +382,10 @@ class DocumentsDialog(QDialog):
desc_item = QTableWidgetItem(description or "") desc_item = QTableWidgetItem(description or "")
self.table.setItem(row_idx, self.DESC_COL, desc_item) self.table.setItem(row_idx, self.DESC_COL, desc_item)
# Col 3: Added at (editable) # Col 3: Added at (not editable)
added_label = uploaded_at added_label = uploaded_at
added_item = QTableWidgetItem(added_label) added_item = QTableWidgetItem(added_label)
added_item.setFlags(added_item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row_idx, self.ADDED_COL, added_item) self.table.setItem(row_idx, self.ADDED_COL, added_item)
# Col 4: Size (not editable) # Col 4: Size (not editable)
@ -423,9 +422,7 @@ class DocumentsDialog(QDialog):
for path in paths: for path in paths:
try: try:
self._db.add_document_from_path( self._db.add_document_from_path(proj_id, path)
proj_id, path, uploaded_at=self._current_date
)
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
QMessageBox.warning( QMessageBox.warning(
self, self,
@ -472,7 +469,7 @@ class DocumentsDialog(QDialog):
def _on_item_changed(self, item: QTableWidgetItem) -> None: def _on_item_changed(self, item: QTableWidgetItem) -> None:
""" """
Handle inline edits to Description, Tags, and Added date. Handle inline edits to Description and Tags.
""" """
if self._reloading_docs or item is None: if self._reloading_docs or item is None:
return return
@ -527,55 +524,9 @@ class DocumentsDialog(QDialog):
item.setForeground(QColor()) item.setForeground(QColor())
finally: finally:
self._reloading_docs = False self._reloading_docs = False
return
# Added date column
if col == self.ADDED_COL:
date_str = item.text().strip()
# Validate date format (YYYY-MM-DD)
if not self._validate_date_format(date_str):
QMessageBox.warning(
self,
strings._("project_documents_title"),
(
strings._("documents_invalid_date_format")
if hasattr(strings, "_")
and callable(getattr(strings, "_"))
and "documents_invalid_date_format" in dir(strings)
else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15"
),
)
# Reload to reset the cell to its original value
self._reload_documents()
return
# Update the database
self._db.update_document_uploaded_at(doc_id, date_str)
return
# --- utils ------------------------------------------------------------- # --- utils -------------------------------------------------------------
def _validate_date_format(self, date_str: str) -> bool:
"""
Validate that a date string is in YYYY-MM-DD format.
Returns True if valid, False otherwise.
"""
import re
from datetime import datetime
# Check basic format with regex
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
return False
# Validate it's a real date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return True
except ValueError:
return False
def _open_document(self, doc_id: int, file_name: str) -> None: def _open_document(self, doc_id: int, file_name: str) -> None:
""" """
Fetch BLOB from DB, write to a temporary file, and open with default app. Fetch BLOB from DB, write to a temporary file, and open with default app.

View file

@ -1,15 +1,20 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument from PySide6.QtGui import (
QShortcut,
QTextCursor,
QTextCharFormat,
QTextDocument,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QWidget, QWidget,
QHBoxLayout,
QLineEdit,
QLabel,
QPushButton,
QCheckBox,
QTextEdit,
) )
from . import strings from . import strings

View file

@ -1,29 +1,22 @@
from __future__ import annotations from __future__ import annotations
import difflib import difflib, re, html as _html
import html as _html
import re
from datetime import datetime from datetime import datetime
from PySide6.QtCore import Qt, Slot
from PySide6.QtCore import QDate, Qt, Slot
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView,
QCalendarWidget,
QDialog, QDialog,
QDialogButtonBox, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QMessageBox,
QPushButton, QPushButton,
QTabWidget, QMessageBox,
QTextBrowser, QTextBrowser,
QVBoxLayout, QTabWidget,
QAbstractItemView,
) )
from . import strings from . import strings
from .theme import ThemeManager
def _markdown_to_text(s: str) -> str: def _markdown_to_text(s: str) -> str:
@ -77,29 +70,16 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
class HistoryDialog(QDialog): class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert.""" """Show versions for a date, preview, diff, and allow revert."""
def __init__( def __init__(self, db, date_iso: str, parent=None):
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(f"{strings._('history')}{date_iso}") self.setWindowTitle(f"{strings._('history')}{date_iso}")
self._db = db self._db = db
self._date = date_iso self._date = date_iso
self._themes = themes
self._versions = [] # list[dict] from DB self._versions = [] # list[dict] from DB
self._current_id = None # id of current self._current_id = None # id of current
root = QVBoxLayout(self) root = QVBoxLayout(self)
# --- Top: date label + change-date button
date_row = QHBoxLayout()
self.date_label = QLabel(strings._("date_label").format(date=date_iso))
date_row.addWidget(self.date_label)
date_row.addStretch(1)
self.change_date_btn = QPushButton(strings._("change_date"))
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
date_row.addWidget(self.change_date_btn)
root.addLayout(date_row)
# Top: list of versions # Top: list of versions
top = QHBoxLayout() top = QHBoxLayout()
self.list = QListWidget() self.list = QListWidget()
@ -137,53 +117,6 @@ class HistoryDialog(QDialog):
self._load_versions() self._load_versions()
@Slot()
def _on_change_date_clicked(self) -> None:
"""Let the user choose a different date and reload entries."""
# Start from current dialog date; fall back to today if invalid
current_qdate = QDate.fromString(self._date, Qt.ISODate)
if not current_qdate.isValid():
current_qdate = QDate.currentDate()
dlg = QDialog(self)
dlg.setWindowTitle(strings._("select_date_title"))
layout = QVBoxLayout(dlg)
calendar = QCalendarWidget(dlg)
calendar.setSelectedDate(current_qdate)
layout.addWidget(calendar)
# Apply the same theming as the main sidebar calendar
if self._themes is not None:
self._themes.register_calendar(calendar)
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec() != QDialog.Accepted:
return
new_qdate = calendar.selectedDate()
new_iso = new_qdate.toString(Qt.ISODate)
if new_iso == self._date:
# No change
return
# Update state
self._date = new_iso
# Update window title and header label
self.setWindowTitle(strings._("for").format(date=new_iso))
self.date_label.setText(strings._("date_label").format(date=new_iso))
# Reload entries for the newly selected date
self._load_versions()
# --- Data/UX helpers --- # --- Data/UX helpers ---
def _load_versions(self): def _load_versions(self):
# [{id,version_no,created_at,note,is_current}] # [{id,version_no,created_at,note,is_current}]

File diff suppressed because it is too large Load diff

View file

@ -4,13 +4,13 @@ from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QVBoxLayout,
QFileDialog,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QVBoxLayout, QDialogButtonBox,
QFileDialog,
) )
from . import strings from . import strings

View file

@ -40,8 +40,6 @@
"next_day": "Next day", "next_day": "Next day",
"today": "Today", "today": "Today",
"show": "Show", "show": "Show",
"edit": "Edit",
"delete": "Delete",
"history": "History", "history": "History",
"export_accessible_flag": "&Export", "export_accessible_flag": "&Export",
"export_entries": "Export entries", "export_entries": "Export entries",
@ -103,7 +101,6 @@
"autosave": "autosave", "autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images", "insert_images": "Insert images",
"images": "Images", "images": "Images",
"reopen_failed": "Re-open failed", "reopen_failed": "Re-open failed",
@ -155,11 +152,6 @@
"tag_already_exists_with_that_name": "A tag already exists with that name", "tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics", "statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics", "main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages",
"stats_group_tags": "Tags",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)", "stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions", "stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions", "stats_page_most_revisions": "Page with most revisions",
@ -174,14 +166,6 @@
"stats_total_documents": "Total documents", "stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents", "stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.", "stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook", "select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.", "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here", "bug_report_placeholder": "Type your bug report here",
@ -209,18 +193,10 @@
"add_project": "Add project", "add_project": "Add project",
"add_time_entry": "Add time entry", "add_time_entry": "Add time entry",
"time_period": "Time period", "time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day", "by_day": "by day",
"by_month": "by month", "by_month": "by month",
"by_week": "by week", "by_week": "by week",
"date_range": "Date range", "date_range": "Date range",
"custom_range": "Custom",
"last_week": "Last week",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
"all_projects": "All projects",
"delete_activity": "Delete activity", "delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?", "delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?", "delete_activity_title": "Delete activity - are you sure?",
@ -230,7 +206,6 @@
"delete_time_entry": "Delete time entry", "delete_time_entry": "Delete time entry",
"group_by": "Group by", "group_by": "Group by",
"hours": "Hours", "hours": "Hours",
"created_at": "Created at",
"invalid_activity_message": "The activity is invalid", "invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity", "invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid", "invalid_project_message": "The project is invalid",
@ -249,8 +224,6 @@
"projects": "Projects", "projects": "Projects",
"rename_activity": "Rename activity", "rename_activity": "Rename activity",
"rename_project": "Rename project", "rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report", "run_report": "Run report",
"add_activity_title": "Add activity", "add_activity_title": "Add activity",
"add_activity_label": "Add an activity", "add_activity_label": "Add an activity",
@ -266,10 +239,10 @@
"select_project_title": "Select project", "select_project_title": "Select project",
"time_log": "Time log", "time_log": "Time log",
"time_log_collapsed_hint": "Time log", "time_log_collapsed_hint": "Time log",
"date_label": "Date: {date}", "time_log_date_label": "Time log date: {date}",
"change_date": "Change date", "time_log_change_date": "Change date",
"select_date_title": "Select date", "time_log_select_date_title": "Select time log date",
"for": "For {date}", "time_log_for": "Time log for {date}",
"time_log_no_date": "Time log", "time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet", "time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report", "time_log_report": "Time log report",
@ -292,9 +265,6 @@
"enable_tags_feature": "Enable Tags", "enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging", "enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders", "enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label":"Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents", "enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session", "pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer", "toolbar_pomodoro_timer": "Time-logging timer",
@ -306,35 +276,18 @@
"pause": "Pause", "pause": "Pause",
"resume": "Resume", "resume": "Resume",
"stop_and_log": "Stop and log", "stop_and_log": "Stop and log",
"manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders",
"once": "once", "once": "once",
"daily": "daily", "daily": "daily",
"weekdays": "weekdays", "weekdays": "weekdays",
"weekly": "weekly", "weekly": "weekly",
"add_reminder": "Add Reminder", "set_reminder": "Set reminder",
"set_reminder": "Set Reminder", "edit_reminder": "Edit reminder",
"edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminder": "Reminder", "reminder": "Reminder",
"reminders": "Reminders",
"time": "Time", "time": "Time",
"once": "Once", "once_today": "Once (today)",
"every_day": "Every day", "every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)", "every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week", "every_week": "Every week",
"every_fortnight": "Every 2 weeks",
"every_month": "Every month (same date)",
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
"week_in_month": "Week in month",
"fortnightly": "Fortnightly",
"monthly_same_date": "Monthly (same date)",
"monthly_nth_weekday": "Monthly (nth weekday)",
"repeat": "Repeat", "repeat": "Repeat",
"monday": "Monday", "monday": "Monday",
"tuesday": "Tuesday", "tuesday": "Tuesday",
@ -343,18 +296,7 @@
"friday": "Friday", "friday": "Friday",
"saturday": "Saturday", "saturday": "Saturday",
"sunday": "Sunday", "sunday": "Sunday",
"monday_short": "Mon",
"tuesday_short": "Tue",
"wednesday_short": "Wed",
"thursday_short": "Thu",
"friday_short": "Fri",
"saturday_short": "Sat",
"sunday_short": "Sun",
"day": "Day", "day": "Day",
"text": "Text",
"type": "Type",
"active": "Active",
"actions": "Actions",
"edit_code_block": "Edit code block", "edit_code_block": "Edit code block",
"delete_code_block": "Delete code block", "delete_code_block": "Delete code block",
"search_result_heading_document": "Document", "search_result_heading_document": "Document",
@ -379,54 +321,5 @@
"documents_search_label": "Search", "documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)", "documents_search_placeholder": "Type to search documents (all projects)",
"todays_documents": "Documents from this day", "todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.", "todays_documents_none": "No documents yet."
"manage_invoices": "Manage Invoices",
"create_invoice": "Create Invoice",
"invoice_amount": "Amount",
"invoice_apply_tax": "Apply Tax",
"invoice_client_address": "Client Address",
"invoice_client_company": "Client Company",
"invoice_client_email": "Client E-mail",
"invoice_client_name": "Client Contact",
"invoice_currency": "Currency",
"invoice_dialog_title": "Create Invoice",
"invoice_due_date": "Due Date",
"invoice_hourly_rate": "Hourly Rate",
"invoice_hours": "Hours",
"invoice_issue_date": "Issue Date",
"invoice_mode_detailed": "Detailed mode",
"invoice_mode_summary": "Summary mode",
"invoice_number": "Invoice Number",
"invoice_save_and_export": "Save and export",
"invoice_save_pdf_title": "Save PDF",
"invoice_subtotal": "Subtotal",
"invoice_summary_default_desc": "Consultant services for the month of",
"invoice_summary_desc": "Summary description",
"invoice_summary_hours": "Summary hours",
"invoice_tax": "Tax details",
"invoice_tax_label": "Tax type",
"invoice_tax_rate": "Tax rate",
"invoice_tax_total": "Tax total",
"invoice_total": "Total",
"invoice_paid_at": "Paid on",
"invoice_payment_note": "Payment notes",
"invoice_project_required_title": "Project required",
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
"invoice_need_report_title": "Report required",
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
"invoice_company_profile": "Business Profile",
"invoice_company_name": "Business Name",
"invoice_company_address": "Address",
"invoice_company_phone": "Phone",
"invoice_company_email": "E-mail",
"invoice_company_tax_id": "Tax number",
"invoice_company_payment_details": "Payment details",
"invoice_company_logo": "Logo",
"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."
} }

View file

@ -274,7 +274,7 @@
"weekly": "hebdomadaire", "weekly": "hebdomadaire",
"edit_reminder": "Modifier le rappel", "edit_reminder": "Modifier le rappel",
"time": "Heure", "time": "Heure",
"once": "Une fois (aujourd'hui)", "once_today": "Une fois (aujourd'hui)",
"every_day": "Tous les jours", "every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)", "every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines", "every_week": "Toutes les semaines",

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QEvent, Qt from PySide6.QtCore import Qt, QEvent
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from . import strings from . import strings
from .theme import ThemeManager from .theme import ThemeManager

View file

@ -2,14 +2,13 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from . import strings
from .main_window import MainWindow
from .settings import APP_NAME, APP_ORG, get_settings from .settings import APP_NAME, APP_ORG, get_settings
from .main_window import MainWindow
from .theme import Theme, ThemeConfig, ThemeManager from .theme import Theme, ThemeConfig, ThemeManager
from . import strings
def main(): def main():

View file

@ -2,21 +2,21 @@ from __future__ import annotations
import datetime import datetime
import os import os
import re
import sys import sys
from pathlib import Path import re
from pathlib import Path
from PySide6.QtCore import ( from PySide6.QtCore import (
QDate, QDate,
QDateTime,
QEvent,
QSettings,
QSignalBlocker,
Qt,
QTime,
QTimer, QTimer,
QUrl, Qt,
QSettings,
Slot, Slot,
QUrl,
QEvent,
QSignalBlocker,
QDateTime,
QTime,
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QAction, QAction,
@ -31,24 +31,23 @@ from PySide6.QtGui import (
QTextListFormat, QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCalendarWidget, QCalendarWidget,
QDialog, QDialog,
QFileDialog, QFileDialog,
QLabel,
QMainWindow, QMainWindow,
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTableView, QTableView,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QLabel,
QPushButton,
QApplication,
) )
from . import strings
from .bug_report_dialog import BugReportDialog from .bug_report_dialog import BugReportDialog
from .db import DBManager from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget from .documents import DocumentsDialog, TodaysDocumentsWidget
@ -58,12 +57,13 @@ from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget, ReminderWebHook from .reminders import UpcomingRemindersWidget
from .save_dialog import SaveDialog from .save_dialog import SaveDialog
from .search import Search from .search import Search
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog from .statistics_dialog import StatisticsDialog
from . import strings
from .tags_widget import PageTagsWidget from .tags_widget import PageTagsWidget
from .theme import ThemeManager from .theme import ThemeManager
from .time_log import TimeLogWidget from .time_log import TimeLogWidget
@ -115,12 +115,8 @@ class MainWindow(QMainWindow):
self.tags.tagAdded.connect(self._on_tag_added) self.tags.tagAdded.connect(self._on_tag_added)
self.upcoming_reminders = UpcomingRemindersWidget(self.db) self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
self.pomodoro_manager = PomodoroManager(self.db, self) self.pomodoro_manager = PomodoroManager(self.db, self)
# Lock the calendar to the left panel at the top to stop it stretching # Lock the calendar to the left panel at the top to stop it stretching
@ -497,7 +493,7 @@ class MainWindow(QMainWindow):
idx = self._tab_index_for_date(date) idx = self._tab_index_for_date(date)
if idx != -1: if idx != -1:
self.tab_widget.setCurrentIndex(idx) self.tab_widget.setCurrentIndex(idx)
# keep calendar selection in sync (don't trigger load) # keep calendar selection in sync (dont trigger load)
from PySide6.QtCore import QSignalBlocker from PySide6.QtCore import QSignalBlocker
with QSignalBlocker(self.calendar): with QSignalBlocker(self.calendar):
@ -520,7 +516,7 @@ class MainWindow(QMainWindow):
editor = MarkdownEditor(self.themes) editor = MarkdownEditor(self.themes)
# Apply user's preferred font size # Apply users preferred font size
self._apply_font_size(editor) self._apply_font_size(editor)
# Set up the editor's event connections # Set up the editor's event connections
@ -822,13 +818,9 @@ class MainWindow(QMainWindow):
Given a 'new day' (system date), return the date we should move Given a 'new day' (system date), return the date we should move
unfinished todos *to*. unfinished todos *to*.
By default, if the new day is Saturday or Sunday we skip ahead to the If the new day is Saturday or Sunday, we skip ahead to the next Monday.
next Monday (i.e., "next available weekday"). If the optional setting Otherwise we just return the same day.
`move_todos_include_weekends` is enabled, we move to the very next day
even if it's a weekend.
""" """
if getattr(self.cfg, "move_todos_include_weekends", False):
return day
# Qt: Monday=1 ... Sunday=7 # Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek() dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7) if dow >= 6: # Saturday (6) or Sunday (7)
@ -883,74 +875,7 @@ class MainWindow(QMainWindow):
target_date = self._rollover_target_date(today) target_date = self._rollover_target_date(today)
target_iso = target_date.toString("yyyy-MM-dd") target_iso = target_date.toString("yyyy-MM-dd")
# Regexes for markdown headings and checkboxes all_unchecked: list[str] = []
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
def _normalize_heading(text: str) -> str:
"""
Strip trailing closing hashes and whitespace, e.g.
"## Foo ###" -> "Foo"
"""
text = text.strip()
text = re.sub(r"\s+#+\s*$", "", text)
return text.strip()
def _insert_todos_under_heading(
target_lines: list[str],
heading_level: int,
heading_text: str,
todos: list[str],
) -> list[str]:
"""Ensure a heading exists and append todos to the end of its section."""
normalized = _normalize_heading(heading_text)
# 1) Find existing heading with same text (any level)
start_idx = None
effective_level = None
for idx, line in enumerate(target_lines):
m = heading_re.match(line)
if not m:
continue
level = len(m.group(1))
text = _normalize_heading(m.group(2))
if text == normalized:
start_idx = idx
effective_level = level
break
# 2) If not found, create a new heading at the end
if start_idx is None:
if target_lines and target_lines[-1].strip():
target_lines.append("") # blank line before new heading
target_lines.append(f"{'#' * heading_level} {heading_text}")
start_idx = len(target_lines) - 1
effective_level = heading_level
# 3) Find the end of this heading's section
end_idx = len(target_lines)
for i in range(start_idx + 1, len(target_lines)):
m = heading_re.match(target_lines[i])
if m and len(m.group(1)) <= effective_level:
end_idx = i
break
# 4) Insert before any trailing blank lines in the section
insert_at = end_idx
while (
insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
):
insert_at -= 1
for todo in todos:
target_lines.insert(insert_at, todo)
insert_at += 1
return target_lines
# Collect moved todos as (heading_info, item_text)
# heading_info is either None or (level, heading_text)
moved_items: list[tuple[tuple[int, str] | None, str]] = []
any_moved = False any_moved = False
# Look back N days (yesterday = 1, up to `days_back`) # Look back N days (yesterday = 1, up to `days_back`)
@ -964,24 +889,14 @@ class MainWindow(QMainWindow):
lines = text.split("\n") lines = text.split("\n")
remaining_lines: list[str] = [] remaining_lines: list[str] = []
moved_from_this_day = False moved_from_this_day = False
current_heading: tuple[int, str] | None = None
for line in lines: 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
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] " # Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
if unchecked_re.match(line): if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
item_text = unchecked_re.sub("", line) r"^\s*-\s*\[☐\]\s+", line
moved_items.append((current_heading, item_text)) ):
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
all_unchecked.append(f"- [ ] {item_text}")
moved_from_this_day = True moved_from_this_day = True
any_moved = True any_moved = True
else: else:
@ -999,45 +914,9 @@ class MainWindow(QMainWindow):
if not any_moved: if not any_moved:
return False return False
# --- Merge all moved items into the *target* date --- # Append everything we collected to the *target* date
unchecked_str = "\n".join(all_unchecked) + "\n"
target_text = self.db.get_entry(target_iso) or "" self._load_selected_date(target_iso, unchecked_str)
target_lines = target_text.split("\n") if target_text else []
by_heading: dict[tuple[int, str], list[str]] = {}
plain_items: list[str] = []
for heading_info, item_text in moved_items:
todo_line = f"- [ ] {item_text}"
if heading_info is None:
# No heading above this checkbox in the source: behave as before
plain_items.append(todo_line)
else:
by_heading.setdefault(heading_info, []).append(todo_line)
# 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
)
# Then append all items without headings at the end, like before
if plain_items:
if target_lines and target_lines[-1].strip():
target_lines.append("") # one blank line before the "unsectioned" todos
target_lines.extend(plain_items)
new_target_text = "\n".join(target_lines)
if not new_target_text.endswith("\n"):
new_target_text += "\n"
# Save the updated target date and load it into the editor
self.db.save_new_version(
target_iso,
new_target_text,
strings._("unchecked_checkbox_items_moved_to_next_day"),
)
self._load_selected_date(target_iso)
return True return True
def _on_date_changed(self): def _on_date_changed(self):
@ -1315,35 +1194,22 @@ class MainWindow(QMainWindow):
self.upcoming_reminders._add_reminder() self.upcoming_reminders._add_reminder()
def _on_timer_requested(self): def _on_timer_requested(self):
"""Toggle the embedded Pomodoro timer for the current line.""" """Start a Pomodoro timer for the current line."""
action = self.toolBar.actTimer
# Turned on -> start a new timer for the current line
if action.isChecked():
editor = getattr(self, "editor", None) editor = getattr(self, "editor", None)
if editor is None: if editor is None:
# No editor; immediately reset the toggle
action.setChecked(False)
return return
# Get the current line text # Get the current line text
line_text = editor.get_current_line_task_text() line_text = editor.get_current_line_task_text()
if not line_text: if not line_text:
line_text = strings._("pomodoro_time_log_default_text") line_text = strings._("pomodoro_time_log_default_text")
# Get current date # Get current date
date_iso = self.editor.current_date.toString("yyyy-MM-dd") date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Start the timer embedded in the sidebar # Start the timer
self.pomodoro_manager.start_timer_for_line(line_text, date_iso) self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
else:
# Turned off -> cancel any running timer and remove the widget
self.pomodoro_manager.cancel_timer()
def _send_reminder_webhook(self, text: str):
if self.cfg.reminders and self.cfg.reminders_webhook_url:
reminder_webhook = ReminderWebHook(text)
reminder_webhook._send()
def _show_flashing_reminder(self, text: str): def _show_flashing_reminder(self, text: str):
""" """
@ -1477,7 +1343,7 @@ class MainWindow(QMainWindow):
else: else:
date_iso = self._current_date_iso() date_iso = self._current_date_iso()
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes) dlg = HistoryDialog(self.db, date_iso, self)
if dlg.exec() == QDialog.Accepted: if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed) # refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso) self._load_selected_date(date_iso)
@ -1570,22 +1436,10 @@ class MainWindow(QMainWindow):
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos) self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.move_todos_include_weekends = getattr(
new_cfg,
"move_todos_include_weekends",
getattr(self.cfg, "move_todos_include_weekends", False),
)
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.reminders_webhook_url = getattr(
new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
)
self.cfg.reminders_webhook_secret = getattr(
new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
)
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)

View file

@ -5,28 +5,28 @@ import re
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from PySide6.QtCore import QRect, Qt, QTimer, QUrl
from PySide6.QtGui import ( from PySide6.QtGui import (
QDesktopServices,
QFont, QFont,
QFontDatabase, QFontDatabase,
QFontMetrics, QFontMetrics,
QImage, QImage,
QMouseEvent, QMouseEvent,
QTextBlock, QTextBlock,
QTextBlockFormat,
QTextCharFormat, QTextCharFormat,
QTextCursor, QTextCursor,
QTextDocument, QTextDocument,
QTextFormat, QTextFormat,
QTextBlockFormat,
QTextImageFormat, QTextImageFormat,
QDesktopServices,
) )
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QDialog, QTextEdit from PySide6.QtWidgets import QDialog, QTextEdit
from . import strings
from .code_block_editor_dialog import CodeBlockEditorDialog
from .markdown_highlighter import MarkdownHighlighter
from .theme import ThemeManager from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter
from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings
class MarkdownEditor(QTextEdit): class MarkdownEditor(QTextEdit):
@ -382,7 +382,7 @@ class MarkdownEditor(QTextEdit):
cursor.removeSelectedText() cursor.removeSelectedText()
cursor.insertText("\n" + new_text + "\n") cursor.insertText("\n" + new_text + "\n")
else: else:
# Empty block - keep one blank line inside the fences # Empty block keep one blank line inside the fences
cursor.removeSelectedText() cursor.removeSelectedText()
cursor.insertText("\n\n") cursor.insertText("\n\n")
cursor.endEditBlock() cursor.endEditBlock()
@ -789,7 +789,7 @@ class MarkdownEditor(QTextEdit):
""" """
# When the user is actively dragging with the mouse, we *do* want the # When the user is actively dragging with the mouse, we *do* want the
# checkbox/bullet to be part of the selection (for deleting whole rows). # checkbox/bullet to be part of the selection (for deleting whole rows).
# So don't rewrite the selection in that case. # So dont rewrite the selection in that case.
if getattr(self, "_mouse_drag_selecting", False): if getattr(self, "_mouse_drag_selecting", False):
return return
@ -863,7 +863,7 @@ class MarkdownEditor(QTextEdit):
): ):
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
# Bullet list - Unicode bullet # Bullet list Unicode bullet
if line.startswith(f"{self._BULLET_DISPLAY} "): if line.startswith(f"{self._BULLET_DISPLAY} "):
return ("bullet", f"{self._BULLET_DISPLAY} ") return ("bullet", f"{self._BULLET_DISPLAY} ")
@ -1055,7 +1055,7 @@ class MarkdownEditor(QTextEdit):
# of list prefixes (checkboxes / bullets / numbers). # of list prefixes (checkboxes / bullets / numbers).
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left): if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
# Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
# document / word-left) - we don't interfere with those. # document / word-left) we don't interfere with those.
if event.modifiers() & Qt.ControlModifier: if event.modifiers() & Qt.ControlModifier:
pass pass
else: else:
@ -1317,43 +1317,15 @@ class MarkdownEditor(QTextEdit):
if icon: if icon:
# absolute document position of the icon # absolute document position of the icon
doc_pos = block.position() + i doc_pos = block.position() + i
r_icon = char_rect_at(doc_pos, icon) r = char_rect_at(doc_pos, icon)
# --- Find where the first non-space "real text" starts --- # ---------- Relax the hit area here ----------
first_idx = i + len(icon) + 1 # skip icon + trailing space # Expand the clickable area horizontally so you don't have to
while first_idx < len(text) and text[first_idx].isspace(): # land exactly on the glyph. This makes the "checkbox zone"
first_idx += 1 # roughly 3× the glyph width, centered on it.
pad = r.width() # one glyph width on each side
# Start with some padding around the icon itself hit_rect = r.adjusted(-pad, 0, pad, 0)
left_pad = r_icon.width() // 2 # ---------------------------------------------
right_pad = r_icon.width() // 2
hit_left = r_icon.left() - left_pad
# If there's actual text after the checkbox, clamp the
# clickable area so it stops *before* the first letter.
if first_idx < len(text):
first_doc_pos = block.position() + first_idx
c_first = QTextCursor(self.document())
c_first.setPosition(first_doc_pos)
first_x = self.cursorRect(c_first).x()
expanded_right = r_icon.right() + right_pad
hit_right = min(expanded_right, first_x)
else:
# No text after the checkbox on this line
hit_right = r_icon.right() + right_pad
# Make sure the rect is at least 1px wide
if hit_right <= hit_left:
hit_right = r_icon.right()
hit_rect = QRect(
hit_left,
r_icon.top(),
max(1, hit_right - hit_left),
r_icon.height(),
)
if hit_rect.contains(pt): if hit_rect.contains(pt):
# Build the replacement: swap ☐ <-> ☑ (keep trailing space) # Build the replacement: swap ☐ <-> ☑ (keep trailing space)
@ -1367,9 +1339,7 @@ class MarkdownEditor(QTextEdit):
edit.setPosition(doc_pos) edit.setPosition(doc_pos)
# icon + space # icon + space
edit.movePosition( edit.movePosition(
QTextCursor.Right, QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
QTextCursor.KeepAnchor,
len(icon) + 1,
) )
edit.insertText(f"{new_icon} ") edit.insertText(f"{new_icon} ")
edit.endEditBlock() edit.endEditBlock()
@ -1397,7 +1367,7 @@ class MarkdownEditor(QTextEdit):
cursor = self.cursorForPosition(event.pos()) cursor = self.cursorForPosition(event.pos())
block = cursor.block() block = cursor.block()
# If we're on or inside a code block, open the editor instead # If were on or inside a code block, open the editor instead
if self._is_inside_code_block(block) or block.text().strip().startswith("```"): if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
# Only swallow the double-click if we actually opened a dialog. # Only swallow the double-click if we actually opened a dialog.
if not self._edit_code_block(block): if not self._edit_code_block(block):

View file

@ -14,7 +14,7 @@ from PySide6.QtGui import (
QTextDocument, QTextDocument,
) )
from .theme import Theme, ThemeManager from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter): class MarkdownHighlighter(QSyntaxHighlighter):
@ -356,6 +356,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
for m in re.finditer(r"[☐☑]", text): for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format) self._overlay_range(m.start(), 1, self.checkbox_format)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text): for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format) self._overlay_range(m.start(), 1, self.bullet_format)

View file

@ -5,11 +5,11 @@ from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QDialog,
QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton, QPushButton,
QVBoxLayout,
QWidget, QWidget,
) )
@ -18,13 +18,16 @@ from .db import DBManager
from .time_log import TimeLogDialog from .time_log import TimeLogDialog
class PomodoroTimer(QFrame): class PomodoroTimer(QDialog):
"""A simple timer for tracking work time on a specific task.""" """A simple timer dialog for tracking work time on a specific task."""
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
def __init__(self, task_text: str, parent: Optional[QWidget] = None): def __init__(self, task_text: str, parent: Optional[QWidget] = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
self.setModal(False)
self.setMinimumWidth(300)
self._task_text = task_text self._task_text = task_text
self._elapsed_seconds = 0 self._elapsed_seconds = 0
@ -40,7 +43,7 @@ class PomodoroTimer(QFrame):
# Timer display # Timer display
self.time_label = QLabel("00:00:00") self.time_label = QLabel("00:00:00")
font = self.time_label.font() font = self.time_label.font()
font.setPointSize(20) font.setPointSize(24)
font.setBold(True) font.setBold(True)
self.time_label.setFont(font) self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter) self.time_label.setAlignment(Qt.AlignCenter)
@ -100,7 +103,7 @@ class PomodoroTimer(QFrame):
self._timer.stop() self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text) self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.close() self.accept()
class PomodoroManager: class PomodoroManager:
@ -112,48 +115,18 @@ class PomodoroManager:
self._active_timer: Optional[PomodoroTimer] = None self._active_timer: Optional[PomodoroTimer] = None
def start_timer_for_line(self, line_text: str, date_iso: str): def start_timer_for_line(self, line_text: str, date_iso: str):
""" """Start a new timer for the given line of text."""
Start a new timer for the given line of text and embed it into the # Stop any existing timer
TimeLogWidget in the main window sidebar. if self._active_timer and self._active_timer.isVisible():
""" self._active_timer.close()
# Cancel any existing timer first
self.cancel_timer()
# The timer lives inside the TimeLogWidget in the sidebar # Create new timer
time_log_widget = getattr(self._parent, "time_log", None) self._active_timer = PomodoroTimer(line_text, self._parent)
if time_log_widget is None:
return
self._active_timer = PomodoroTimer(line_text, time_log_widget)
self._active_timer.timerStopped.connect( self._active_timer.timerStopped.connect(
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
) )
# Ask the TimeLogWidget to own and display the widget
if hasattr(time_log_widget, "show_pomodoro_widget"):
time_log_widget.show_pomodoro_widget(self._active_timer)
else:
# Fallback - just attach it as a child widget
self._active_timer.setParent(time_log_widget)
self._active_timer.show() self._active_timer.show()
def cancel_timer(self):
"""Cancel any running timer without logging and remove it from the sidebar."""
if not self._active_timer:
return
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None and hasattr(
time_log_widget, "clear_pomodoro_widget"
):
time_log_widget.clear_pomodoro_widget()
else:
# Fallback if the widget API doesn't exist
self._active_timer.setParent(None)
self._active_timer.deleteLater()
self._active_timer = None
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): 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.""" """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) # Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes)
@ -164,19 +137,6 @@ class PomodoroManager:
if hours < 0.25: if hours < 0.25:
hours = 0.25 hours = 0.25
# Untoggle the toolbar button without retriggering the slot
tool_bar = getattr(self._parent, "toolBar", None)
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
action = tool_bar.actTimer
was_blocked = action.blockSignals(True)
try:
action.setChecked(False)
finally:
action.blockSignals(was_blocked)
# Remove the embedded widget
self.cancel_timer()
# Open time log dialog # Open time log dialog
dlg = TimeLogDialog( dlg = TimeLogDialog(
self._db, self._db,
@ -195,13 +155,3 @@ class PomodoroManager:
# Show the dialog # Show the dialog
dlg.exec() dlg.exec()
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None:
# Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
# reload the summary so the TimeLogWidget in sidebar updates its totals
time_log_widget._reload_summary()
if not time_log_widget.toggle_btn.isChecked():
time_log_widget.summary_label.setText(
strings._("time_log_collapsed_hint")
)

View file

@ -4,37 +4,32 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateEdit,
QDialog, QDialog,
QFormLayout, QVBoxLayout,
QFrame,
QHBoxLayout, QHBoxLayout,
QHeaderView, QFormLayout,
QLineEdit, QLineEdit,
QComboBox,
QTimeEdit,
QPushButton,
QFrame,
QWidget,
QToolButton,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle, QStyle,
QSizePolicy,
QMessageBox,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QTimeEdit, QAbstractItemView,
QToolButton, QHeaderView,
QVBoxLayout,
QWidget,
) )
from . import strings from . import strings
from .db import DBManager from .db import DBManager
from .settings import load_db_config
import requests
class ReminderType(Enum): class ReminderType(Enum):
@ -42,9 +37,6 @@ class ReminderType(Enum):
DAILY = strings._("daily") DAILY = strings._("daily")
WEEKDAYS = strings._("weekdays") # Mon-Fri WEEKDAYS = strings._("weekdays") # Mon-Fri
WEEKLY = strings._("weekly") # specific day of week WEEKLY = strings._("weekly") # specific day of week
FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
@dataclass @dataclass
@ -80,22 +72,6 @@ class ReminderDialog(QDialog):
self.text_edit.setText(reminder.text) self.text_edit.setText(reminder.text)
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit) self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
# Date
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
if reminder and reminder.date_iso:
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if d.isValid():
self.date_edit.setDate(d)
else:
self.date_edit.setDate(QDate.currentDate())
else:
self.date_edit.setDate(QDate.currentDate())
self.form.addRow("&" + strings._("date") + ":", self.date_edit)
# Time # Time
self.time_edit = QTimeEdit() self.time_edit = QTimeEdit()
self.time_edit.setDisplayFormat("HH:mm") self.time_edit.setDisplayFormat("HH:mm")
@ -103,22 +79,15 @@ class ReminderDialog(QDialog):
parts = reminder.time_str.split(":") parts = reminder.time_str.split(":")
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1]))) self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
else: else:
# Default to 5 minutes in the future self.time_edit.setTime(QTime.currentTime())
future = QTime.currentTime().addSecs(5 * 60)
self.time_edit.setTime(future)
self.form.addRow("&" + strings._("time") + ":", self.time_edit) self.form.addRow("&" + strings._("time") + ":", self.time_edit)
# Recurrence type # Recurrence type
self.type_combo = QComboBox() self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once"), ReminderType.ONCE) self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY) self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS) self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY) self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
self.type_combo.addItem(
strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
)
if reminder: if reminder:
for i in range(self.type_combo.count()): for i in range(self.type_combo.count()):
@ -146,31 +115,12 @@ class ReminderDialog(QDialog):
if reminder and reminder.weekday is not None: if reminder and reminder.weekday is not None:
self.weekday_combo.setCurrentIndex(reminder.weekday) self.weekday_combo.setCurrentIndex(reminder.weekday)
else: else:
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1) self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo) self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
day_label = self.form.labelForField(self.weekday_combo) day_label = self.form.labelForField(self.weekday_combo)
day_label.setVisible(False) day_label.setVisible(False)
self.nth_spin = QSpinBox()
self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
self.nth_spin.setValue(1)
# If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
if (
reminder
and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
and reminder.date_iso
):
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if anchor.isValid():
nth_index = (anchor.day() - 1) // 7 # 0-based
self.nth_spin.setValue(nth_index + 1)
self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
nth_label = self.form.labelForField(self.nth_spin)
nth_label.setVisible(False)
self.nth_spin.setVisible(False)
layout.addLayout(self.form) layout.addLayout(self.form)
# Buttons # Buttons
@ -191,31 +141,11 @@ class ReminderDialog(QDialog):
self._on_type_changed() self._on_type_changed()
def _on_type_changed(self): def _on_type_changed(self):
"""Show/hide weekday / nth selectors based on reminder type.""" """Show/hide weekday selector based on reminder type."""
reminder_type = self.type_combo.currentData() reminder_type = self.type_combo.currentData()
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
show_weekday = reminder_type in (
ReminderType.WEEKLY,
ReminderType.MONTHLY_NTH_WEEKDAY,
)
self.weekday_combo.setVisible(show_weekday)
day_label = self.form.labelForField(self.weekday_combo) day_label = self.form.labelForField(self.weekday_combo)
day_label.setVisible(show_weekday) day_label.setVisible(reminder_type == ReminderType.WEEKLY)
show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
nth_label = self.form.labelForField(self.nth_spin)
self.nth_spin.setVisible(show_nth)
nth_label.setVisible(show_nth)
# For new reminders, when switching to a type that uses a weekday,
# snap the weekday to match the currently selected date.
if reminder_type in (
ReminderType.WEEKLY,
ReminderType.MONTHLY_NTH_WEEKDAY,
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
if 0 <= dow < self.weekday_combo.count():
self.weekday_combo.setCurrentIndex(dow)
def get_reminder(self) -> Reminder: def get_reminder(self) -> Reminder:
"""Get the configured reminder.""" """Get the configured reminder."""
@ -224,39 +154,13 @@ class ReminderDialog(QDialog):
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}" time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
weekday = None weekday = None
if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY): if reminder_type == ReminderType.WEEKLY:
weekday = self.weekday_combo.currentData() weekday = self.weekday_combo.currentData()
date_iso = None date_iso = None
anchor_date = self.date_edit.date()
if reminder_type == ReminderType.ONCE: if reminder_type == ReminderType.ONCE:
# Fire once, on the chosen calendar date at the chosen time # Right now this just means "today at the chosen time".
date_iso = anchor_date.toString("yyyy-MM-dd") date_iso = QDate.currentDate().toString("yyyy-MM-dd")
elif reminder_type == ReminderType.FORTNIGHTLY:
# Anchor: the chosen calendar date. Every 14 days from this date.
date_iso = anchor_date.toString("yyyy-MM-dd")
elif reminder_type == ReminderType.MONTHLY_DATE:
# Anchor: the chosen calendar date. "Same date each month"
date_iso = anchor_date.toString("yyyy-MM-dd")
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
weekday = self.weekday_combo.currentData()
nth_index = self.nth_spin.value() - 1 # 0-based
first = QDate(anchor_date.year(), anchor_date.month(), 1)
target_dow = weekday + 1 # Qt: Monday=1
offset = (target_dow - first.dayOfWeek() + 7) % 7
anchor = first.addDays(offset + nth_index * 7)
# If nth weekday doesn't exist in this month, fall back to the last such weekday
if anchor.month() != anchor_date.month():
anchor = anchor.addDays(-7)
date_iso = anchor.toString("yyyy-MM-dd")
return Reminder( return Reminder(
id=self._reminder.id if self._reminder else None, id=self._reminder.id if self._reminder else None,
@ -283,7 +187,7 @@ class UpcomingRemindersWidget(QFrame):
# Header with toggle button # Header with toggle button
self.toggle_btn = QToolButton() self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("upcoming_reminders")) self.toggle_btn.setText("Upcoming Reminders")
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True) self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False) self.toggle_btn.setChecked(False)
@ -292,7 +196,7 @@ class UpcomingRemindersWidget(QFrame):
self.add_btn = QToolButton() self.add_btn = QToolButton()
self.add_btn.setText("") self.add_btn.setText("")
self.add_btn.setToolTip(strings._("add_reminder")) self.add_btn.setToolTip("Add Reminder")
self.add_btn.setAutoRaise(True) self.add_btn.setAutoRaise(True)
self.add_btn.clicked.connect(self._add_reminder) self.add_btn.clicked.connect(self._add_reminder)
@ -300,7 +204,7 @@ class UpcomingRemindersWidget(QFrame):
self.manage_btn.setIcon( self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView) self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
) )
self.manage_btn.setToolTip(strings._("manage_reminders")) self.manage_btn.setToolTip("Manage All Reminders")
self.manage_btn.setAutoRaise(True) self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._manage_reminders) self.manage_btn.clicked.connect(self._manage_reminders)
@ -335,36 +239,43 @@ class UpcomingRemindersWidget(QFrame):
main.addWidget(self.body) main.addWidget(self.body)
# Timer to check and fire reminders # Timer to check and fire reminders
# # Start by syncing to the next minute boundary
# We tick once per second, but only hit the DB when the clock is self._check_timer = QTimer(self)
# exactly on a :00 second. That way a reminder for HH:MM fires at self._check_timer.timeout.connect(self._check_reminders)
# HH:MM:00, independent of when it was created.
self._tick_timer = QTimer(self)
self._tick_timer.setInterval(1000) # 1 second
self._tick_timer.timeout.connect(self._on_tick)
self._tick_timer.start()
# Also check once on startup so we don't miss reminders that # Calculate milliseconds until next minute (HH:MM:00)
# should have fired a moment ago when the app wasn't running.
QTimer.singleShot(0, self._check_reminders)
def _on_tick(self) -> None:
"""Called every second; run reminder check only on exact minute boundaries."""
now = QDateTime.currentDateTime() now = QDateTime.currentDateTime()
if now.time().second() == 0: current_second = now.time().second()
# Only do the heavier DB work once per minute, at HH:MM:00, current_msec = now.time().msec()
# so reminders are aligned to the clock and not to when they
# were created. # Milliseconds until next minute
self._check_reminders(now) ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
def __del__(self): def __del__(self):
"""Cleanup timers when widget is destroyed.""" """Cleanup timers when widget is destroyed."""
try: try:
if hasattr(self, "_tick_timer") and self._tick_timer: if hasattr(self, "_check_timer") and self._check_timer:
self._tick_timer.stop() self._check_timer.stop()
except Exception: if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
pass # Ignore any cleanup errors pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool): def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list.""" """Toggle visibility of reminder list."""
self.body.setVisible(checked) self.body.setVisible(checked)
@ -417,99 +328,41 @@ class UpcomingRemindersWidget(QFrame):
self.reminder_list.addItem(item) self.reminder_list.addItem(item)
if not upcoming: if not upcoming:
item = QListWidgetItem(strings._("no_upcoming_reminders")) item = QListWidgetItem("No upcoming reminders")
item.setFlags(Qt.NoItemFlags) item.setFlags(Qt.NoItemFlags)
self.reminder_list.addItem(item) self.reminder_list.addItem(item)
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool: def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
"""Check if a reminder should fire on a given date.""" """Check if a reminder should fire on a given date."""
rtype = reminder.reminder_type if reminder.reminder_type == ReminderType.ONCE:
if rtype == ReminderType.ONCE:
if reminder.date_iso: if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso return date.toString("yyyy-MM-dd") == reminder.date_iso
return False return False
elif reminder.reminder_type == ReminderType.DAILY:
if rtype == ReminderType.DAILY:
return True return True
elif reminder.reminder_type == ReminderType.WEEKDAYS:
if rtype == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7 # Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5 return 1 <= date.dayOfWeek() <= 5
elif reminder.reminder_type == ReminderType.WEEKLY:
if rtype == ReminderType.WEEKLY:
# Qt: Monday=1, reminder: Monday=0 # Qt: Monday=1, reminder: Monday=0
return date.dayOfWeek() - 1 == reminder.weekday return date.dayOfWeek() - 1 == reminder.weekday
if rtype == ReminderType.FORTNIGHTLY:
if not reminder.date_iso:
return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if not anchor.isValid() or date < anchor:
return False
days = anchor.daysTo(date)
return days % 14 == 0
if rtype == ReminderType.MONTHLY_DATE:
if not reminder.date_iso:
return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if not anchor.isValid():
return False
anchor_day = anchor.day()
# Clamp to the last day of this month (for 29/30/31)
first_of_month = QDate(date.year(), date.month(), 1)
last_of_month = first_of_month.addMonths(1).addDays(-1)
target_day = min(anchor_day, last_of_month.day())
return date.day() == target_day
if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
if not reminder.date_iso or reminder.weekday is None:
return False return False
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd") def _check_reminders(self):
if not anchor.isValid(): """Check if any reminders should fire now."""
return False
# Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
anchor_n = (anchor.day() - 1) // 7
target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
# Compute the anchor_n-th target weekday in this month
first = QDate(date.year(), date.month(), 1)
offset = (target_dow - first.dayOfWeek() + 7) % 7
candidate = first.addDays(offset + anchor_n * 7)
# If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
if candidate.month() != date.month():
return False
return date == candidate
return False
def _check_reminders(self, now: QDateTime | None = None):
"""
Check and trigger due reminders.
This uses absolute clock time, so a reminder for HH:MM will fire
when the system clock reaches HH:MM:00, independent of when the
reminder was created.
"""
# Guard: Check if database connection is valid # Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return return
if now is None:
now = QDateTime.currentDateTime() now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
today = now.date()
reminders = self._db.get_all_reminders() reminders = self._db.get_all_reminders()
# Small grace window (in seconds) so we still fire reminders if
# the app was just opened or the event loop was briefly busy.
GRACE_WINDOW_SECS = 120 # 2 minutes
for reminder in reminders: for reminder in reminders:
if not reminder.active: if not reminder.active:
continue continue
@ -517,29 +370,22 @@ class UpcomingRemindersWidget(QFrame):
if not self._should_fire_on_date(reminder, today): if not self._should_fire_on_date(reminder, today):
continue continue
# Parse time: stored as "HH:MM", we treat that as HH:MM:00 # Parse time
hour, minute = map(int, reminder.time_str.split(":")) hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0)) target = QDateTime(today, QTime(hour, minute, 0))
# Skip if this reminder is still in the future # Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
if now < target: seconds_diff = current_minute.secsTo(target)
continue if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
# How long ago should this reminder have fired?
seconds_late = target.secsTo(now) # target -> now
if 0 <= seconds_late <= GRACE_WINDOW_SECS:
# Check if we haven't already fired this occurrence
if not hasattr(self, "_fired_reminders"): if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {} self._fired_reminders = {}
reminder_key = (reminder.id, target.toString()) reminder_key = (reminder.id, target.toString())
if reminder_key in self._fired_reminders: # Only fire once per reminder per target time
continue if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
# Mark as fired and emit
self._fired_reminders[reminder_key] = now
self.reminderTriggered.emit(reminder.text) self.reminderTriggered.emit(reminder.text)
# For ONCE reminders, deactivate after firing # For ONCE reminders, deactivate after firing
@ -576,8 +422,8 @@ class UpcomingRemindersWidget(QFrame):
if not selected_items: if not selected_items:
return return
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu from PySide6.QtWidgets import QMenu
from PySide6.QtGui import QAction
menu = QMenu(self) menu = QMenu(self)
@ -585,7 +431,7 @@ class UpcomingRemindersWidget(QFrame):
if len(selected_items) == 1: if len(selected_items) == 1:
reminder = selected_items[0].data(Qt.UserRole) reminder = selected_items[0].data(Qt.UserRole)
if reminder: if reminder:
edit_action = QAction(strings._("edit"), self) edit_action = QAction("Edit", self)
edit_action.triggered.connect( edit_action.triggered.connect(
lambda: self._edit_reminder(selected_items[0]) lambda: self._edit_reminder(selected_items[0])
) )
@ -593,13 +439,9 @@ class UpcomingRemindersWidget(QFrame):
# Delete option for any selection # Delete option for any selection
if len(selected_items) == 1: if len(selected_items) == 1:
delete_text = strings._("delete") delete_text = "Delete"
else: else:
delete_text = ( delete_text = f"Delete {len(selected_items)} Reminders"
strings._("delete")
+ f" {len(selected_items)} "
+ strings._("reminders")
)
delete_action = QAction(delete_text, self) delete_action = QAction(delete_text, self)
delete_action.triggered.connect(lambda: self._delete_selected_reminders()) delete_action.triggered.connect(lambda: self._delete_selected_reminders())
@ -626,31 +468,15 @@ class UpcomingRemindersWidget(QFrame):
# Confirmation message # Confirmation message
if len(unique_reminders) == 1: if len(unique_reminders) == 1:
reminder = list(unique_reminders.values())[0] reminder = list(unique_reminders.values())[0]
msg = ( msg = f"Delete reminder '{reminder.text}'?"
strings._("delete")
+ " "
+ strings._("reminder")
+ f" '{reminder.text}'?"
)
if reminder.reminder_type != ReminderType.ONCE: if reminder.reminder_type != ReminderType.ONCE:
msg += ( msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
"\n\n"
+ strings._("this_is_a_reminder_of_type")
+ f" '{reminder.reminder_type.value}'. "
+ strings._("deleting_it_will_remove_all_future_occurrences")
)
else: else:
msg = ( msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
strings._("delete")
+ f"{len(unique_reminders)} "
+ strings._("reminders")
+ " ?\n\n"
+ strings._("this_will_delete_the_actual_reminders")
)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
strings._("delete_reminders"), "Delete Reminders",
msg, msg,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
@ -663,18 +489,13 @@ class UpcomingRemindersWidget(QFrame):
def _delete_reminder(self, reminder): def _delete_reminder(self, reminder):
"""Delete a single reminder after confirmation.""" """Delete a single reminder after confirmation."""
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?" msg = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE: if reminder.reminder_type != ReminderType.ONCE:
msg += ( msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
"\n\n"
+ strings._("this_is_a_reminder_of_type")
+ f" '{reminder.reminder_type.value}'. "
+ strings._("deleting_it_will_remove_all_future_occurrences")
)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
strings._("delete_reminder"), "Delete Reminder",
msg, msg,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
@ -699,40 +520,32 @@ class ManageRemindersDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.setWindowTitle(strings._("manage_reminders")) self.setWindowTitle("Manage Reminders")
self.setMinimumSize(700, 500) self.setMinimumSize(700, 500)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# Reminder list table # Reminder list table
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(6) self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ ["Text", "Time", "Type", "Active", "Actions"]
strings._("text"),
strings._("date"),
strings._("time"),
strings._("type"),
strings._("active"),
strings._("actions"),
]
) )
self.table.horizontalHeader().setStretchLastSection(False) self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
layout.addWidget(self.table) layout.addWidget(self.table)
# Buttons # Buttons
btn_layout = QHBoxLayout() btn_layout = QHBoxLayout()
add_btn = QPushButton(strings._("add_reminder")) add_btn = QPushButton("Add Reminder")
add_btn.clicked.connect(self._add_reminder) add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn) btn_layout.addWidget(add_btn)
btn_layout.addStretch() btn_layout.addStretch()
close_btn = QPushButton(strings._("close")) close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn) btn_layout.addWidget(close_btn)
@ -756,105 +569,48 @@ class ManageRemindersDialog(QDialog):
text_item.setData(Qt.UserRole, reminder) text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item) self.table.setItem(row, 0, text_item)
# Date
date_display = ""
if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if d.isValid():
date_display = d.toString("yyyy-MM-dd")
else:
date_display = reminder.date_iso
date_item = QTableWidgetItem(date_display)
self.table.setItem(row, 1, date_item)
# Time # Time
time_item = QTableWidgetItem(reminder.time_str) time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 2, time_item) self.table.setItem(row, 1, time_item)
# Type # Type
base_type_strs = { type_str = {
ReminderType.ONCE: "Once", ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily", ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays", ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly", ReminderType.WEEKLY: "Weekly",
ReminderType.FORTNIGHTLY: "Fortnightly", }.get(reminder.reminder_type, "Unknown")
ReminderType.MONTHLY_DATE: "Monthly (date)",
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
}
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
# Short day names we can reuse
days_short = [
strings._("monday_short"),
strings._("tuesday_short"),
strings._("wednesday_short"),
strings._("thursday_short"),
strings._("friday_short"),
strings._("saturday_short"),
strings._("sunday_short"),
]
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
# Show something like: Monthly (3rd Mon)
day_name = ""
if reminder.weekday is not None and 0 <= reminder.weekday < len(
days_short
):
day_name = days_short[reminder.weekday]
nth_label = ""
if reminder.date_iso:
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if anchor.isValid():
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
if 0 <= nth_index < len(ordinals):
nth_label = ordinals[nth_index]
parts = []
if nth_label:
parts.append(nth_label)
if day_name:
parts.append(day_name)
if parts:
type_str = f"Monthly ({' '.join(parts)})"
# else: fall back to the generic "Monthly (nth weekday)"
else:
# For weekly / fortnightly types, still append the day name
if ( if (
reminder.reminder_type reminder.reminder_type == ReminderType.WEEKLY
in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
and reminder.weekday is not None and reminder.weekday is not None
and 0 <= reminder.weekday < len(days_short)
): ):
type_str += f" ({days_short[reminder.weekday]})" days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
type_str += f" ({days[reminder.weekday]})"
type_item = QTableWidgetItem(type_str) type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 3, type_item) self.table.setItem(row, 2, type_item)
# Active # Active
active_item = QTableWidgetItem("" if reminder.active else "") active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 4, active_item) self.table.setItem(row, 3, active_item)
# Actions # Actions
actions_widget = QWidget() actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget) actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(2, 2, 2, 2) actions_layout.setContentsMargins(2, 2, 2, 2)
edit_btn = QPushButton(strings._("edit")) edit_btn = QPushButton("Edit")
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r)) edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn) actions_layout.addWidget(edit_btn)
delete_btn = QPushButton(strings._("delete")) delete_btn = QPushButton("Delete")
delete_btn.clicked.connect( delete_btn.clicked.connect(
lambda checked, r=reminder: self._delete_reminder(r) lambda checked, r=reminder: self._delete_reminder(r)
) )
actions_layout.addWidget(delete_btn) actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 5, actions_widget) self.table.setCellWidget(row, 4, actions_widget)
def _add_reminder(self): def _add_reminder(self):
"""Add a new reminder.""" """Add a new reminder."""
@ -876,8 +632,8 @@ class ManageRemindersDialog(QDialog):
"""Delete a reminder.""" """Delete a reminder."""
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
strings._("delete_reminder"), "Delete Reminder",
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?", f"Delete reminder '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
) )
@ -885,33 +641,3 @@ class ManageRemindersDialog(QDialog):
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id) self._db.delete_reminder(reminder.id)
self._load_reminders() self._load_reminders()
class ReminderWebHook:
def __init__(self, text):
self.text = text
self.cfg = load_db_config()
def _send(self):
payload: dict[str, str] = {
"reminder": self.text,
}
url = self.cfg.reminders_webhook_url
secret = self.cfg.reminders_webhook_secret
_headers = {}
if secret:
_headers["X-Bouquin-Secret"] = secret
if url:
try:
requests.post(
url,
json=payload,
timeout=10,
headers=_headers,
)
except Exception:
# We did our best
pass

View file

@ -3,7 +3,13 @@ from __future__ import annotations
import datetime import datetime
from PySide6.QtGui import QFontMetrics from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QDialogButtonBox,
)
from . import strings from . import strings

View file

@ -6,12 +6,12 @@ from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QSizePolicy, QSizePolicy,
QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QSettings, QStandardPaths from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig from .db import DBConfig
@ -42,16 +41,10 @@ def load_db_config() -> DBConfig:
idle = s.value("ui/idle_minutes", 15, type=int) idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str) theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool) move_todos = s.value("ui/move_todos", False, type=bool)
move_todos_include_weekends = s.value(
"ui/move_todos_include_weekends", False, type=bool
)
tags = s.value("ui/tags", True, type=bool) tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool) time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool) reminders = s.value("ui/reminders", True, type=bool)
reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str)
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
documents = s.value("ui/documents", True, type=bool) documents = s.value("ui/documents", True, type=bool)
invoicing = s.value("ui/invoicing", False, type=bool)
locale = s.value("ui/locale", "en", type=str) locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int) font_size = s.value("ui/font_size", 11, type=int)
return DBConfig( return DBConfig(
@ -60,14 +53,10 @@ def load_db_config() -> DBConfig:
idle_minutes=idle, idle_minutes=idle,
theme=theme, theme=theme,
move_todos=move_todos, move_todos=move_todos,
move_todos_include_weekends=move_todos_include_weekends,
tags=tags, tags=tags,
time_log=time_log, time_log=time_log,
reminders=reminders, reminders=reminders,
reminders_webhook_url=reminders_webhook_url,
reminders_webhook_secret=reminders_webhook_secret,
documents=documents, documents=documents,
invoicing=invoicing,
locale=locale, locale=locale,
font_size=font_size, font_size=font_size,
) )
@ -80,13 +69,9 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos)) s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
s.setValue("ui/tags", str(cfg.tags)) s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders)) s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url))
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
s.setValue("ui/documents", str(cfg.documents)) s.setValue("ui/documents", str(cfg.documents))
s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size)) s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -2,37 +2,34 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame, QFrame,
QGroupBox, QGroupBox,
QHBoxLayout,
QLabel, QLabel,
QLineEdit, QHBoxLayout,
QMessageBox, QVBoxLayout,
QPushButton, QPushButton,
QDialogButtonBox,
QRadioButton, QRadioButton,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
QTabWidget, QMessageBox,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget, QWidget,
QTabWidget,
) )
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from . import strings
from .db import DBConfig, DBManager from .db import DBConfig, DBManager
from .key_prompt import KeyPrompt
from .settings import load_db_config, save_db_config from .settings import load_db_config, save_db_config
from .theme import Theme from .theme import Theme
from .key_prompt import KeyPrompt
from . import strings
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
@ -45,7 +42,7 @@ class SettingsDialog(QDialog):
self.current_settings = load_db_config() self.current_settings = load_db_config()
self.setMinimumWidth(600) self.setMinimumWidth(480)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
# --- Tabs ---------------------------------------------------------- # --- Tabs ----------------------------------------------------------
@ -169,25 +166,6 @@ class SettingsDialog(QDialog):
self.move_todos.setCursor(Qt.PointingHandCursor) self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos) features_layout.addWidget(self.move_todos)
# Optional: allow moving to the very next day even if it is a weekend.
self.move_todos_include_weekends = QCheckBox(
strings._("move_todos_include_weekends")
)
self.move_todos_include_weekends.setChecked(
getattr(self.current_settings, "move_todos_include_weekends", False)
)
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
move_todos_opts = QWidget()
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
move_todos_opts_layout.setSpacing(4)
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
features_layout.addWidget(move_todos_opts)
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
self.tags = QCheckBox(strings._("enable_tags_feature")) self.tags = QCheckBox(strings._("enable_tags_feature"))
self.tags.setChecked(self.current_settings.tags) self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor) self.tags.setCursor(Qt.PointingHandCursor)
@ -198,145 +176,17 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor) self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log) features_layout.addWidget(self.time_log)
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
self.invoicing.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.invoicing)
# Invoicing only if time_log is enabled
if not self.current_settings.time_log:
self.invoicing.setChecked(False)
self.invoicing.setEnabled(False)
self.time_log.toggled.connect(self._on_time_log_toggled)
# --- Reminders feature + webhook options -------------------------
self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders) self.reminders.setChecked(self.current_settings.reminders)
self.reminders.toggled.connect(self._on_reminders_toggled)
self.reminders.setCursor(Qt.PointingHandCursor) self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders) features_layout.addWidget(self.reminders)
# Container for reminder-specific options, indented under the checkbox
self.reminders_options_container = QWidget()
reminders_options_layout = QVBoxLayout(self.reminders_options_container)
reminders_options_layout.setContentsMargins(24, 0, 0, 0)
reminders_options_layout.setSpacing(4)
self.reminders_options_toggle = QToolButton()
self.reminders_options_toggle.setText(
strings._("reminders_webhook_section_title")
)
self.reminders_options_toggle.setCheckable(True)
self.reminders_options_toggle.setChecked(False)
self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_toggle.clicked.connect(
self._on_reminders_options_toggled
)
toggle_row = QHBoxLayout()
toggle_row.addWidget(self.reminders_options_toggle)
toggle_row.addStretch()
reminders_options_layout.addLayout(toggle_row)
# Actual options (labels + QLineEdits)
self.reminders_options_widget = QWidget()
options_form = QFormLayout(self.reminders_options_widget)
options_form.setContentsMargins(0, 0, 0, 0)
options_form.setSpacing(4)
self.reminders_webhook_url = QLineEdit(
self.current_settings.reminders_webhook_url or ""
)
self.reminders_webhook_secret = QLineEdit(
self.current_settings.reminders_webhook_secret or ""
)
self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
options_form.addRow(
strings._("reminders_webhook_url_label") + ":",
self.reminders_webhook_url,
)
options_form.addRow(
strings._("reminders_webhook_secret_label") + ":",
self.reminders_webhook_secret,
)
reminders_options_layout.addWidget(self.reminders_options_widget)
features_layout.addWidget(self.reminders_options_container)
self.reminders_options_container.setVisible(self.reminders.isChecked())
self.reminders_options_widget.setVisible(False)
self.documents = QCheckBox(strings._("enable_documents_feature")) self.documents = QCheckBox(strings._("enable_documents_feature"))
self.documents.setChecked(self.current_settings.documents) self.documents.setChecked(self.current_settings.documents)
self.documents.setCursor(Qt.PointingHandCursor) self.documents.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.documents) features_layout.addWidget(self.documents)
layout.addWidget(features_group) layout.addWidget(features_group)
# --- Invoicing / company profile section -------------------------
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
invoicing_layout = QFormLayout(self.invoicing_group)
profile = self._db.get_company_profile() or (
None,
None,
None,
None,
None,
None,
None,
)
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
self.company_name_edit = QLineEdit(name or "")
self.company_address_edit = QTextEdit(address or "")
self.company_phone_edit = QLineEdit(phone or "")
self.company_email_edit = QLineEdit(email or "")
self.company_tax_id_edit = QLineEdit(tax_id or "")
self.company_payment_details_edit = QTextEdit()
self.company_payment_details_edit.setPlainText(payment_details or "")
invoicing_layout.addRow(
strings._("invoice_company_name") + ":", self.company_name_edit
)
invoicing_layout.addRow(
strings._("invoice_company_address") + ":", self.company_address_edit
)
invoicing_layout.addRow(
strings._("invoice_company_phone") + ":", self.company_phone_edit
)
invoicing_layout.addRow(
strings._("invoice_company_email") + ":", self.company_email_edit
)
invoicing_layout.addRow(
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
)
invoicing_layout.addRow(
strings._("invoice_company_payment_details") + ":",
self.company_payment_details_edit,
)
# Logo picker - store bytes on self._logo_bytes
self._logo_bytes = logo_bytes
logo_row = QHBoxLayout()
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
if logo_bytes:
self.logo_label.setText(strings._("invoice_company_logo_set"))
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
logo_btn.clicked.connect(self._on_choose_logo)
logo_row.addWidget(self.logo_label)
logo_row.addWidget(logo_btn)
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
# Show/hide this whole block based on invoicing checkbox
self.invoicing_group.setVisible(self.invoicing.isChecked())
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
layout.addWidget(self.invoicing_group)
layout.addStretch() layout.addStretch()
return page return page
@ -460,92 +310,18 @@ class SettingsDialog(QDialog):
idle_minutes=self.idle_spin.value(), idle_minutes=self.idle_spin.value(),
theme=selected_theme.value, theme=selected_theme.value,
move_todos=self.move_todos.isChecked(), move_todos=self.move_todos.isChecked(),
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
tags=self.tags.isChecked(), tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(), time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(), reminders=self.reminders.isChecked(),
reminders_webhook_url=self.reminders_webhook_url.text().strip() or None,
reminders_webhook_secret=self.reminders_webhook_secret.text().strip()
or None,
documents=self.documents.isChecked(), documents=self.documents.isChecked(),
invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False
),
locale=self.locale_combobox.currentText(), locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(), font_size=self.font_size.value(),
) )
save_db_config(self._cfg) save_db_config(self._cfg)
# Save company profile only if invoicing is enabled
if self.invoicing.isChecked() and self.time_log.isChecked():
self._db.save_company_profile(
name=self.company_name_edit.text().strip() or None,
address=self.company_address_edit.toPlainText().strip() or None,
phone=self.company_phone_edit.text().strip() or None,
email=self.company_email_edit.text().strip() or None,
tax_id=self.company_tax_id_edit.text().strip() or None,
payment_details=self.company_payment_details_edit.toPlainText().strip()
or None,
logo=getattr(self, "_logo_bytes", None),
)
self.parent().themes.set(selected_theme) self.parent().themes.set(selected_theme)
self.accept() self.accept()
def _on_reminders_options_toggled(self, checked: bool) -> None:
"""
Expand/collapse the advanced reminders options (webhook URL/secret).
"""
if checked:
self.reminders_options_toggle.setArrowType(Qt.DownArrow)
self.reminders_options_widget.show()
else:
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_widget.hide()
def _on_reminders_toggled(self, checked: bool) -> None:
"""
Conditionally show reminder webhook options depending
on if the reminders feature is toggled on or off.
"""
if hasattr(self, "reminders_options_container"):
self.reminders_options_container.setVisible(checked)
# When turning reminders off, also collapse the section
if not checked and hasattr(self, "reminders_options_toggle"):
self.reminders_options_toggle.setChecked(False)
self._on_reminders_options_toggled(False)
def _on_time_log_toggled(self, checked: bool) -> None:
"""
Enforce 'invoicing depends on time logging'.
"""
if not checked:
# Turn off + disable invoicing if time logging is disabled
self.invoicing.setChecked(False)
self.invoicing.setEnabled(False)
else:
# Let the user enable invoicing when time logging is enabled
self.invoicing.setEnabled(True)
def _on_choose_logo(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
strings._("company_logo_choose"),
"",
"Images (*.png *.jpg *.jpeg *.bmp)",
)
if not path:
return
try:
with open(path, "rb") as f:
self._logo_bytes = f.read()
self.logo_label.setText(Path(path).name)
except OSError as exc:
QMessageBox.warning(self, strings._("error"), str(exc))
def _change_key(self): def _change_key(self):
p1 = KeyPrompt( p1 = KeyPrompt(
self, self,

View file

@ -3,25 +3,26 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
from typing import Dict from typing import Dict
from PySide6.QtCore import QSize, Qt, Signal from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPen from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QVBoxLayout,
QFormLayout, QFormLayout,
QLabel,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QComboBox,
QScrollArea, QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget, QWidget,
QSizePolicy,
) )
from . import strings from . import strings
from .db import DBManager from .db import DBManager
from .settings import load_db_config from .settings import load_db_config
# ---------- Activity heatmap ---------- # ---------- Activity heatmap ----------
@ -150,7 +151,7 @@ class DateHeatmap(QWidget):
fm = painter.fontMetrics() fm = painter.fontMetrics()
# --- weekday labels on left ------------------------------------- # --- weekday labels on left -------------------------------------
# Python's weekday(): Monday=0 ... Sunday=6 # Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"] weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7): for dow in range(7):
@ -215,7 +216,7 @@ class DateHeatmap(QWidget):
col = int((x - self._margin_left) // cell_span) # week index col = int((x - self._margin_left) // cell_span) # week index
row = int((y - self._margin_top) // cell_span) # dow (0..6) row = int((y - self._margin_top) // cell_span) # dow (0..6)
# Only 7 rows (Mon-Sun) # Only 7 rows (MonSun)
if not (0 <= row < 7): if not (0 <= row < 7):
return return
@ -248,9 +249,8 @@ class StatisticsDialog(QDialog):
self._db = db self._db = db
self.setWindowTitle(strings._("statistics")) self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(650) self.setMinimumWidth(600)
self.setMinimumHeight(650) self.setMinimumHeight(400)
root = QVBoxLayout(self) root = QVBoxLayout(self)
( (
@ -264,23 +264,12 @@ class StatisticsDialog(QDialog):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = self._gather_stats() ) = self._gather_stats()
self.cfg = load_db_config()
# Optional: per-date document counts for the heatmap. # Optional: per-date document counts for the heatmap.
# This uses project_documents.uploaded_at aggregated by day, if the
# Documents feature is enabled.
self.cfg = load_db_config()
documents_by_date: Dict[_dt.date, int] = {} documents_by_date: Dict[_dt.date, int] = {}
total_documents = 0 total_documents = 0
date_most_documents: _dt.date | None = None date_most_documents: _dt.date | None = None
@ -300,176 +289,68 @@ class StatisticsDialog(QDialog):
key=lambda item: (-item[1], item[0]), key=lambda item: (-item[1], item[0]),
)[0] )[0]
# For the heatmap # for the heatmap
self._documents_by_date = documents_by_date self._documents_by_date = documents_by_date
self._time_by_date = time_minutes_by_date
self._reminders_by_date = reminders_by_date
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
# ------------------------------------------------------------------ # --- Numeric summary at the top ----------------------------------
# Feature groups form = QFormLayout()
# ------------------------------------------------------------------ root.addLayout(form)
# --- Pages / words / revisions ----------------------------------- form.addRow(
pages_group = QGroupBox(strings._("stats_group_pages"))
pages_form = QFormLayout(pages_group)
pages_form.addRow(
strings._("stats_pages_with_content"), strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)), QLabel(str(pages_with_content)),
) )
pages_form.addRow( form.addRow(
strings._("stats_total_revisions"), strings._("stats_total_revisions"),
QLabel(str(total_revisions)), QLabel(str(total_revisions)),
) )
if page_most_revisions: if page_most_revisions:
pages_form.addRow( form.addRow(
strings._("stats_page_most_revisions"), strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"), QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
) )
else: else:
pages_form.addRow( form.addRow(strings._("stats_page_most_revisions"), QLabel(""))
strings._("stats_page_most_revisions"),
QLabel(""),
)
pages_form.addRow( form.addRow(
strings._("stats_total_words"), strings._("stats_total_words"),
QLabel(str(total_words)), QLabel(str(total_words)),
) )
root.addWidget(pages_group) # Tags
# --- Tags ---------------------------------------------------------
if self.cfg.tags: if self.cfg.tags:
tags_group = QGroupBox(strings._("stats_group_tags")) form.addRow(
tags_form = QFormLayout(tags_group)
tags_form.addRow(
strings._("stats_unique_tags"), strings._("stats_unique_tags"),
QLabel(str(unique_tags)), QLabel(str(unique_tags)),
) )
if page_most_tags: if page_most_tags:
tags_form.addRow( form.addRow(
strings._("stats_page_most_tags"), strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"), QLabel(f"{page_most_tags} ({page_most_tags_count})"),
) )
else: else:
tags_form.addRow( form.addRow(strings._("stats_page_most_tags"), QLabel(""))
strings._("stats_page_most_tags"),
QLabel(""),
)
root.addWidget(tags_group) # Documents
if date_most_documents:
# --- Documents ---------------------------------------------------- form.addRow(
if self.cfg.documents:
docs_group = QGroupBox(strings._("stats_group_documents"))
docs_form = QFormLayout(docs_group)
docs_form.addRow(
strings._("stats_total_documents"), strings._("stats_total_documents"),
QLabel(str(total_documents)), QLabel(str(total_documents)),
) )
if date_most_documents:
doc_most_label = ( doc_most_label = (
f"{date_most_documents.isoformat()} ({date_most_documents_count})" f"{date_most_documents.isoformat()} ({date_most_documents_count})"
) )
else:
doc_most_label = ""
docs_form.addRow( form.addRow(
strings._("stats_date_most_documents"), strings._("stats_date_most_documents"),
QLabel(doc_most_label), QLabel(doc_most_label),
) )
root.addWidget(docs_group) # --- Heatmap with switcher ---------------------------------------
if words_by_date or revisions_by_date or documents_by_date:
# --- Time logging -------------------------------------------------
if self.cfg.time_log:
time_group = QGroupBox(strings._("stats_group_time_logging"))
time_form = QFormLayout(time_group)
total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0
time_form.addRow(
strings._("stats_time_total_hours"),
QLabel(f"{total_hours:.2f}h"),
)
if day_most_time:
day_hours = (
day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0
)
day_label = f"{day_most_time} ({day_hours:.2f}h)"
else:
day_label = ""
time_form.addRow(
strings._("stats_time_day_most_hours"),
QLabel(day_label),
)
if project_most_minutes_name:
proj_hours = (
project_most_minutes / 60.0 if project_most_minutes else 0.0
)
proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)"
else:
proj_label = ""
time_form.addRow(
strings._("stats_time_project_most_hours"),
QLabel(proj_label),
)
if activity_most_minutes_name:
act_hours = (
activity_most_minutes / 60.0 if activity_most_minutes else 0.0
)
act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)"
else:
act_label = ""
time_form.addRow(
strings._("stats_time_activity_most_hours"),
QLabel(act_label),
)
root.addWidget(time_group)
# --- Reminders ----------------------------------------------------
if self.cfg.reminders:
rem_group = QGroupBox(strings._("stats_group_reminders"))
rem_form = QFormLayout(rem_group)
rem_form.addRow(
strings._("stats_total_reminders"),
QLabel(str(total_reminders)),
)
if day_most_reminders:
rem_label = f"{day_most_reminders} ({day_most_reminders_count})"
else:
rem_label = ""
rem_form.addRow(
strings._("stats_date_most_reminders"),
QLabel(rem_label),
)
root.addWidget(rem_group)
# ------------------------------------------------------------------
# Heatmap with metric switcher
# ------------------------------------------------------------------
if (
words_by_date
or revisions_by_date
or documents_by_date
or time_minutes_by_date
or reminders_by_date
):
group = QGroupBox(strings._("stats_activity_heatmap")) group = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group) group_layout = QVBoxLayout(group)
@ -478,30 +359,18 @@ class StatisticsDialog(QDialog):
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric"))) combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
self.metric_combo = QComboBox() self.metric_combo = QComboBox()
self.metric_combo.addItem(strings._("stats_metric_words"), "words") self.metric_combo.addItem(strings._("stats_metric_words"), "words")
self.metric_combo.addItem( self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
strings._("stats_metric_revisions"),
"revisions",
)
if documents_by_date: if documents_by_date:
self.metric_combo.addItem( self.metric_combo.addItem(
strings._("stats_metric_documents"), strings._("stats_metric_documents"), "documents"
"documents",
)
if self.cfg.time_log and time_minutes_by_date:
self.metric_combo.addItem(
strings._("stats_metric_hours"),
"hours",
)
if self.cfg.reminders and reminders_by_date:
self.metric_combo.addItem(
strings._("stats_metric_reminders"),
"reminders",
) )
combo_row.addWidget(self.metric_combo) combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1) combo_row.addStretch(1)
group_layout.addLayout(combo_row) group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap() self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
@ -518,8 +387,6 @@ class StatisticsDialog(QDialog):
else: else:
root.addWidget(QLabel(strings._("stats_no_data"))) root.addWidget(QLabel(strings._("stats_no_data")))
self.resize(self.sizeHint().width(), self.sizeHint().height())
# ---------- internal helpers ---------- # ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None: def _apply_metric(self, metric: str) -> None:
@ -527,10 +394,6 @@ class StatisticsDialog(QDialog):
self._heatmap.set_data(self._revisions_by_date) self._heatmap.set_data(self._revisions_by_date)
elif metric == "documents": elif metric == "documents":
self._heatmap.set_data(self._documents_by_date) self._heatmap.set_data(self._documents_by_date)
elif metric == "hours":
self._heatmap.set_data(self._time_by_date)
elif metric == "reminders":
self._heatmap.set_data(self._reminders_by_date)
else: else:
self._heatmap.set_data(self._words_by_date) self._heatmap.set_data(self._words_by_date)

View file

@ -1,5 +1,5 @@
import json
from importlib.resources import files from importlib.resources import files
import json
# Get list of locales # Get list of locales
root = files("bouquin") / "locales" root = files("bouquin") / "locales"

View file

@ -1,22 +1,22 @@
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QColorDialog,
QDialog, QDialog,
QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QInputDialog,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget, QTreeWidget,
QTreeWidgetItem, QTreeWidgetItem,
QVBoxLayout, QPushButton,
QLabel,
QColorDialog,
QMessageBox,
QInputDialog,
) )
from sqlcipher3.dbapi2 import IntegrityError
from . import strings
from .db import DBManager from .db import DBManager
from .settings import load_db_config from .settings import load_db_config
from . import strings
from sqlcipher3.dbapi2 import IntegrityError
class TagBrowserDialog(QDialog): class TagBrowserDialog(QDialog):

View file

@ -4,16 +4,16 @@ from typing import Optional
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCompleter,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QVBoxLayout,
QWidget,
QToolButton,
QLabel, QLabel,
QLineEdit, QLineEdit,
QSizePolicy, QSizePolicy,
QStyle, QStyle,
QToolButton, QCompleter,
QVBoxLayout,
QWidget,
) )
from . import strings from . import strings

View file

@ -1,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from weakref import WeakSet from PySide6.QtGui import QPalette, QColor, QGuiApplication, QTextCharFormat
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from PySide6.QtCore import QObject, Signal, Qt
from weakref import WeakSet
class Theme(Enum): class Theme(Enum):

View file

@ -2,49 +2,48 @@ from __future__ import annotations
import csv import csv
import html import html
from collections import defaultdict
from datetime import datetime
from typing import Optional
from PySide6.QtCore import QDate, Qt, QUrl, Signal from collections import defaultdict
from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument from typing import Optional
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import (
QAbstractItemView,
QCalendarWidget,
QComboBox,
QCompleter,
QDateEdit,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QStyle,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
from . import strings from PySide6.QtCore import Qt, QDate, QUrl
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import (
QCalendarWidget,
QDialog,
QDialogButtonBox,
QFrame,
QVBoxLayout,
QHBoxLayout,
QWidget,
QFileDialog,
QFormLayout,
QLabel,
QComboBox,
QLineEdit,
QDoubleSpinBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QTabWidget,
QListWidget,
QListWidgetItem,
QDateEdit,
QMessageBox,
QCompleter,
QToolButton,
QSizePolicy,
QStyle,
QInputDialog,
)
from .db import DBManager from .db import DBManager
from .settings import load_db_config
from .theme import ThemeManager from .theme import ThemeManager
from . import strings
class TimeLogWidget(QFrame): class TimeLogWidget(QFrame):
@ -53,8 +52,6 @@ class TimeLogWidget(QFrame):
Shown in the left sidebar above the Tags widget. Shown in the left sidebar above the Tags widget.
""" """
remindersChanged = Signal()
def __init__( def __init__(
self, self,
db: DBManager, db: DBManager,
@ -63,7 +60,6 @@ class TimeLogWidget(QFrame):
): ):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.cfg = load_db_config()
self._themes = themes self._themes = themes
self._current_date: Optional[str] = None self._current_date: Optional[str] = None
@ -85,15 +81,6 @@ class TimeLogWidget(QFrame):
self.log_btn.setAutoRaise(True) self.log_btn.setAutoRaise(True)
self.log_btn.clicked.connect(self._open_dialog_log_only) self.log_btn.clicked.connect(self._open_dialog_log_only)
self.report_btn = QToolButton()
self.report_btn.setText("📈")
self.report_btn.setAutoRaise(True)
self.report_btn.clicked.connect(self._on_run_report)
if self.cfg.invoicing:
self.report_btn.setToolTip(strings._("reporting_and_invoicing"))
else:
self.report_btn.setToolTip(strings._("reporting"))
self.open_btn = QToolButton() self.open_btn = QToolButton()
self.open_btn.setIcon( self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView) self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
@ -107,7 +94,6 @@ class TimeLogWidget(QFrame):
header.addWidget(self.toggle_btn) header.addWidget(self.toggle_btn)
header.addStretch(1) header.addStretch(1)
header.addWidget(self.log_btn) header.addWidget(self.log_btn)
header.addWidget(self.report_btn)
header.addWidget(self.open_btn) header.addWidget(self.open_btn)
# Body: simple summary label for the day # Body: simple summary label for the day
@ -119,8 +105,6 @@ class TimeLogWidget(QFrame):
self.summary_label = QLabel(strings._("time_log_no_entries")) self.summary_label = QLabel(strings._("time_log_no_entries"))
self.summary_label.setWordWrap(True) self.summary_label.setWordWrap(True)
self.body_layout.addWidget(self.summary_label) self.body_layout.addWidget(self.summary_label)
# Optional embedded Pomodoro timer widget lives underneath the summary.
self._pomodoro_widget: Optional[QWidget] = None
self.body.setVisible(False) self.body.setVisible(False)
main = QVBoxLayout(self) main = QVBoxLayout(self)
@ -136,40 +120,8 @@ class TimeLogWidget(QFrame):
if not self.toggle_btn.isChecked(): if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint")) self.summary_label.setText(strings._("time_log_collapsed_hint"))
def show_pomodoro_widget(self, widget: QWidget) -> None:
"""Embed Pomodoro timer widget in the body area."""
if self._pomodoro_widget is not None:
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = widget
self.body_layout.addWidget(widget)
widget.show()
# Ensure the body is visible so the timer is obvious
self.body.setVisible(True)
self.toggle_btn.setChecked(True)
self.toggle_btn.setArrowType(Qt.DownArrow)
def clear_pomodoro_widget(self) -> None:
"""Remove any embedded Pomodoro timer widget."""
if self._pomodoro_widget is None:
return
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = None
# ----- internals --------------------------------------------------- # ----- internals ---------------------------------------------------
def _on_run_report(self) -> None:
dlg = TimeReportDialog(self._db, self)
# Bubble the remindersChanged signal further up
dlg.remindersChanged.connect(self.remindersChanged.emit)
dlg.exec()
def _on_toggle(self, checked: bool) -> None: def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked) self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
@ -252,6 +204,12 @@ class TimeLogWidget(QFrame):
class TimeLogDialog(QDialog): class TimeLogDialog(QDialog):
""" """
Per-day time log dialog. Per-day time log dialog.
Lets you:
1) choose a project
2) enter an activity (free-text with autocomplete)
3) enter time in decimal hours (0.25 = 15 min, 0.17 10 min)
4) manage entries for this date
""" """
def __init__( def __init__(
@ -268,16 +226,13 @@ class TimeLogDialog(QDialog):
self._themes = themes self._themes = themes
self._date_iso = date_iso self._date_iso = date_iso
self._current_entry_id: Optional[int] = None self._current_entry_id: Optional[int] = None
self.cfg = load_db_config() # Guard flag used when repopulating the table so we dont treat
# Guard flag used when repopulating the table so we don't treat
# programmatic item changes as user edits. # programmatic item changes as user edits.
self._reloading_entries: bool = False self._reloading_entries: bool = False
self.total_hours = 0
self.close_after_add = close_after_add self.close_after_add = close_after_add
self.setWindowTitle(strings._("for").format(date=date_iso)) self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.resize(900, 600) self.resize(900, 600)
root = QVBoxLayout(self) root = QVBoxLayout(self)
@ -285,12 +240,13 @@ class TimeLogDialog(QDialog):
# --- Top: date label + change-date button # --- Top: date label + change-date button
date_row = QHBoxLayout() date_row = QHBoxLayout()
self.date_label = QLabel(strings._("date_label").format(date=date_iso)) self.date_label = QLabel(strings._("time_log_date_label").format(date=date_iso))
date_row.addWidget(self.date_label) date_row.addWidget(self.date_label)
date_row.addStretch(1) date_row.addStretch(1)
self.change_date_btn = QPushButton(strings._("change_date")) # You can i18n this later if you like
self.change_date_btn = QPushButton(strings._("time_log_change_date"))
self.change_date_btn.clicked.connect(self._on_change_date_clicked) self.change_date_btn.clicked.connect(self._on_change_date_clicked)
date_row.addWidget(self.change_date_btn) date_row.addWidget(self.change_date_btn)
@ -342,21 +298,24 @@ class TimeLogDialog(QDialog):
self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.clicked.connect(self._on_delete_entry)
self.delete_btn.setEnabled(False) self.delete_btn.setEnabled(False)
self.report_btn = QPushButton("&" + strings._("run_report"))
self.report_btn.clicked.connect(self._on_run_report)
btn_row.addStretch(1) btn_row.addStretch(1)
btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn) btn_row.addWidget(self.delete_btn)
btn_row.addWidget(self.report_btn)
root.addLayout(btn_row) root.addLayout(btn_row)
# --- Table of entries for this date # --- Table of entries for this date
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(5) self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("project"), strings._("project"),
strings._("activity"), strings._("activity"),
strings._("note"), strings._("note"),
strings._("hours"), strings._("hours"),
strings._("created_at"),
] ]
) )
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
@ -365,7 +324,6 @@ class TimeLogDialog(QDialog):
self.table.horizontalHeader().setSectionResizeMode( self.table.horizontalHeader().setSectionResizeMode(
3, QHeaderView.ResizeToContents 3, QHeaderView.ResizeToContents
) )
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.itemSelectionChanged.connect(self._on_row_selected) self.table.itemSelectionChanged.connect(self._on_row_selected)
@ -373,19 +331,8 @@ class TimeLogDialog(QDialog):
self.table.itemChanged.connect(self._on_table_item_changed) self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
# --- Total time, Reporting and Close button # --- Close button
close_row = QHBoxLayout() close_row = QHBoxLayout()
self.total_label = QLabel(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
if self.cfg.invoicing:
self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing"))
else:
self.report_btn = QPushButton("&" + strings._("reporting"))
self.report_btn.clicked.connect(self._on_run_report)
close_row.addWidget(self.total_label)
close_row.addWidget(self.report_btn)
close_row.addStretch(1) close_row.addStretch(1)
close_btn = QPushButton(strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
@ -434,16 +381,11 @@ class TimeLogDialog(QDialog):
note = r[7] or "" note = r[7] or ""
minutes = r[6] minutes = r[6]
hours = minutes / 60.0 hours = minutes / 60.0
created_at = r[8]
ca_utc = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
ca_local = ca_utc.astimezone()
created = f"{ca_local.day} {ca_local.strftime('%b %Y, %H:%M%p')}"
item_proj = QTableWidgetItem(project_name) item_proj = QTableWidgetItem(project_name)
item_act = QTableWidgetItem(activity_name) item_act = QTableWidgetItem(activity_name)
item_note = QTableWidgetItem(note) item_note = QTableWidgetItem(note)
item_hours = QTableWidgetItem(f"{hours:.2f}") item_hours = QTableWidgetItem(f"{hours:.2f}")
item_created_at = QTableWidgetItem(created)
# store the entry id on the first column # store the entry id on the first column
item_proj.setData(Qt.ItemDataRole.UserRole, entry_id) item_proj.setData(Qt.ItemDataRole.UserRole, entry_id)
@ -452,16 +394,9 @@ class TimeLogDialog(QDialog):
self.table.setItem(row_idx, 1, item_act) self.table.setItem(row_idx, 1, item_act)
self.table.setItem(row_idx, 2, item_note) self.table.setItem(row_idx, 2, item_note)
self.table.setItem(row_idx, 3, item_hours) self.table.setItem(row_idx, 3, item_hours)
self.table.setItem(row_idx, 4, item_created_at)
finally: finally:
self._reloading_entries = False self._reloading_entries = False
total_minutes = sum(r[6] for r in rows)
self.total_hours = total_minutes / 60.0
self.total_label.setText(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
self._current_entry_id = None self._current_entry_id = None
self.delete_btn.setEnabled(False) self.delete_btn.setEnabled(False)
self.add_update_btn.setText("&" + strings._("add_time_entry")) self.add_update_btn.setText("&" + strings._("add_time_entry"))
@ -477,7 +412,7 @@ class TimeLogDialog(QDialog):
current_qdate = QDate.currentDate() current_qdate = QDate.currentDate()
dlg = QDialog(self) dlg = QDialog(self)
dlg.setWindowTitle(strings._("select_date_title")) dlg.setWindowTitle(strings._("time_log_select_date_title"))
layout = QVBoxLayout(dlg) layout = QVBoxLayout(dlg)
@ -508,8 +443,8 @@ class TimeLogDialog(QDialog):
self._date_iso = new_iso self._date_iso = new_iso
# Update window title and header label # Update window title and header label
self.setWindowTitle(strings._("for").format(date=new_iso)) self.setWindowTitle(strings._("time_log_for").format(date=new_iso))
self.date_label.setText(strings._("date_label").format(date=new_iso)) self.date_label.setText(strings._("time_log_date_label").format(date=new_iso))
# Reload entries for the newly selected date # Reload entries for the newly selected date
self._reload_entries() self._reload_entries()
@ -619,7 +554,7 @@ class TimeLogDialog(QDialog):
hours_item = self.table.item(row, 3) hours_item = self.table.item(row, 3)
if proj_item is None or act_item is None or hours_item is None: if proj_item is None or act_item is None or hours_item is None:
# Incomplete row - nothing to do. # Incomplete row nothing to do.
return return
# Recover the entry id from the hidden UserRole on the project cell # Recover the entry id from the hidden UserRole on the project cell
@ -828,7 +763,7 @@ class TimeCodeManagerDialog(QDialog):
try: try:
self._db.add_project(name) self._db.add_project(name)
except ValueError: except ValueError:
# Empty / invalid name - nothing to do, but be defensive # Empty / invalid name nothing to do, but be defensive
QMessageBox.warning( QMessageBox.warning(
self, self,
strings._("invalid_project_title"), strings._("invalid_project_title"),
@ -1006,21 +941,17 @@ class TimeReportDialog(QDialog):
Shows decimal hours per time period. Shows decimal hours per time period.
""" """
remindersChanged = Signal()
def __init__(self, db: DBManager, parent=None): def __init__(self, db: DBManager, parent=None):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self.cfg = load_db_config()
# state for last run # state for last run
self._last_rows: list[tuple[str, str, str, str, int]] = [] self._last_rows: list[tuple[str, str, int]] = []
self._last_total_minutes: int = 0 self._last_total_minutes: int = 0
self._last_project_name: str = "" self._last_project_name: str = ""
self._last_start: str = "" self._last_start: str = ""
self._last_end: str = "" self._last_end: str = ""
self._last_gran_label: str = "" self._last_gran_label: str = ""
self._last_time_logs: list = []
self.setWindowTitle(strings._("time_log_report")) self.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400) self.resize(600, 400)
@ -1028,62 +959,30 @@ class TimeReportDialog(QDialog):
root = QVBoxLayout(self) root = QVBoxLayout(self)
form = QFormLayout() form = QFormLayout()
self.invoice_btn = QPushButton(strings._("create_invoice"))
self.invoice_btn.clicked.connect(self._on_create_invoice)
self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
# Project # Project
self.project_combo = QComboBox() self.project_combo = QComboBox()
self.project_combo.addItem(strings._("all_projects"), None)
self.project_combo.currentIndexChanged.connect(
self._update_invoice_button_state
)
self._update_invoice_button_state()
for proj_id, name in self._db.list_projects(): for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id) self.project_combo.addItem(name, proj_id)
form.addRow(strings._("project"), self.project_combo) form.addRow(strings._("project"), self.project_combo)
# Date range # Date range
today = QDate.currentDate() today = QDate.currentDate()
start_of_month = QDate(today.year(), today.month(), 1) self.from_date = QDateEdit(today.addDays(-7))
self.range_preset = QComboBox()
self.range_preset.addItem(strings._("custom_range"), "custom")
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._("this_month"), "this_month")
self.range_preset.addItem(strings._("this_year"), "this_year")
self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed)
self.from_date = QDateEdit(start_of_month)
self.from_date.setCalendarPopup(True) self.from_date.setCalendarPopup(True)
self.to_date = QDateEdit(today) self.to_date = QDateEdit(today)
self.to_date.setCalendarPopup(True) self.to_date.setCalendarPopup(True)
range_row = QHBoxLayout() range_row = QHBoxLayout()
range_row.addWidget(self.range_preset)
range_row.addWidget(self.from_date) range_row.addWidget(self.from_date)
range_row.addWidget(QLabel("")) range_row.addWidget(QLabel(""))
range_row.addWidget(self.to_date) range_row.addWidget(self.to_date)
form.addRow(strings._("date_range"), range_row) form.addRow(strings._("date_range"), range_row)
# After widgets are created, choose default preset
idx = self.range_preset.findData("this_month")
if idx != -1:
self.range_preset.setCurrentIndex(idx)
# Granularity # Granularity
self.granularity = QComboBox() self.granularity = QComboBox()
self.granularity.addItem(strings._("dont_group"), "none")
self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_day"), "day")
self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month") self.granularity.addItem(strings._("by_month"), "month")
self.granularity.addItem(strings._("by_activity"), "activity")
form.addRow(strings._("group_by"), self.granularity) form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form) root.addLayout(form)
@ -1103,18 +1002,13 @@ class TimeReportDialog(QDialog):
run_row.addWidget(run_btn) run_row.addWidget(run_btn)
run_row.addWidget(export_btn) run_row.addWidget(export_btn)
run_row.addWidget(pdf_btn) run_row.addWidget(pdf_btn)
# Only show invoicing if the feature is enabled
if getattr(self._db.cfg, "invoicing", False):
run_row.addWidget(self.invoice_btn)
run_row.addWidget(self.manage_invoices_btn)
root.addLayout(run_row) root.addLayout(run_row)
# Table # Table
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(5) self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("project"),
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("note"), strings._("note"),
@ -1124,9 +1018,8 @@ class TimeReportDialog(QDialog):
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode( self.table.horizontalHeader().setSectionResizeMode(
4, QHeaderView.ResizeToContents 3, QHeaderView.ResizeToContents
) )
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
@ -1142,181 +1035,36 @@ class TimeReportDialog(QDialog):
close_row.addWidget(close_btn) close_row.addWidget(close_btn)
root.addLayout(close_row) root.addLayout(close_row)
def _configure_table_columns(self, granularity: str) -> None:
if granularity == "none":
# Show notes
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("time_period"),
strings._("activity"),
strings._("note"),
strings._("hours"),
]
)
# project, period, activity, note stretch; hours shrink
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
elif granularity == "activity":
# Grouped by activity only: no time period, no note column
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("hours"),
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
else:
# Grouped: no note column
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("time_period"),
strings._("activity"),
strings._("hours"),
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
def _on_range_preset_changed(self, index: int) -> None:
preset = self.range_preset.currentData()
today = QDate.currentDate()
if preset == "today":
start = end = today
elif preset == "this_week":
# Monday-based week, clamp end to today
# dayOfWeek(): Monday=1, Sunday=7
start = today.addDays(1 - today.dayOfWeek())
end = today
elif preset == "last_week":
# Compute Monday-Sunday of the previous week (Monday-based weeks)
# 1. Monday of this week:
start_of_this_week = today.addDays(1 - today.dayOfWeek())
# 2. Last week is 7 days before that:
start = start_of_this_week.addDays(-7) # last week's Monday
end = start_of_this_week.addDays(-1) # last week's Sunday
elif preset == "this_month":
start = QDate(today.year(), today.month(), 1)
end = today
elif preset == "this_year":
start = QDate(today.year(), 1, 1)
end = today
else: # "custom" - leave fields as user-set
return
# Update date edits without triggering anything else
self.from_date.blockSignals(True)
self.to_date.blockSignals(True)
self.from_date.setDate(start)
self.to_date.setDate(end)
self.from_date.blockSignals(False)
self.to_date.blockSignals(False)
def _run_report(self): def _run_report(self):
idx = self.project_combo.currentIndex() idx = self.project_combo.currentIndex()
if idx < 0: if idx < 0:
return return
proj_id = int(self.project_combo.itemData(idx))
proj_data = self.project_combo.itemData(idx)
start = self.from_date.date().toString("yyyy-MM-dd") start = self.from_date.date().toString("yyyy-MM-dd")
end = self.to_date.date().toString("yyyy-MM-dd") end = self.to_date.date().toString("yyyy-MM-dd")
gran = self.granularity.currentData() gran = self.granularity.currentData()
# Keep human-friendly copies for PDF header
self._last_project_name = self.project_combo.currentText()
self._last_start = start self._last_start = start
self._last_end = end self._last_end = end
self._last_gran_label = self.granularity.currentText() self._last_gran_label = self.granularity.currentText()
self._last_gran = gran # remember which grouping was used
self._configure_table_columns(gran) rows = self._db.time_report(proj_id, start, end, gran)
rows_for_table: list[tuple[str, str, str, str, int]] = [] self._last_rows = rows
self._last_total_minutes = sum(r[3] for r in rows)
if proj_data is None: self.table.setRowCount(len(rows))
# All projects for i, (time_period, activity_name, note, minutes) in enumerate(rows):
self._last_all_projects = True
self._last_time_logs = []
self._last_project_name = strings._("all_projects")
rows_for_table = self._db.time_report_all(start, end, gran)
else:
self._last_all_projects = False
proj_id = int(proj_data)
self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end)
project_name = self.project_combo.currentText()
self._last_project_name = project_name
per_project_rows = self._db.time_report(proj_id, start, end, gran)
# Adapt DB rows (period, activity, note, minutes) → include project
rows_for_table = [
(project_name, period, activity, note, minutes)
for (period, activity, note, minutes) in per_project_rows
]
# Store for export
self._last_rows = rows_for_table
self._last_total_minutes = sum(r[4] for r in rows_for_table)
# Per-project totals
self._last_project_totals = defaultdict(int)
for project, _period, _activity, _note, minutes in rows_for_table:
self._last_project_totals[project] += minutes
# Populate table
self.table.setRowCount(len(rows_for_table))
for i, (project, time_period, activity_name, note, minutes) in enumerate(
rows_for_table
):
hrs = minutes / 60.0 hrs = minutes / 60.0
if self._last_gran == "activity": self.table.setItem(i, 0, QTableWidgetItem(time_period))
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(activity_name)) self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}")) self.table.setItem(i, 2, QTableWidgetItem(note))
else:
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
if self._last_gran == "none":
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
else:
# no note column
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
# Summary label - include per-project totals when in "all projects" mode
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
if self._last_all_projects:
per_project_bits = [
f"{proj}: {mins/60.0:.2f}h"
for proj, mins in sorted(self._last_project_totals.items())
]
self.total_label.setText(
strings._("time_report_total").format(hours=total_hours)
+ " ("
+ ", ".join(per_project_bits)
+ ")"
)
else:
self.total_label.setText( self.total_label.setText(
strings._("time_report_total").format(hours=total_hours) strings._("time_report_total").format(hours=total_hours)
) )
@ -1345,45 +1093,25 @@ class TimeReportDialog(QDialog):
with open(filename, "w", newline="", encoding="utf-8") as f: with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f) writer = csv.writer(f)
gran = getattr(self, "_last_gran", "day")
show_note = gran == "none"
show_period = gran != "activity"
# Header # Header
header: list[str] = [strings._("project")] writer.writerow(
if show_period: [
header.append(strings._("time_period")) strings._("time_period"),
header.append(strings._("activity")) strings._("activity"),
if show_note: strings._("note"),
header.append(strings._("note")) strings._("hours"),
header.append(strings._("hours")) ]
writer.writerow(header) )
# Data rows # Data rows
for ( for time_period, activity_name, note, minutes in self._last_rows:
project,
time_period,
activity_name,
note,
minutes,
) in self._last_rows:
hours = minutes / 60.0 hours = minutes / 60.0
row: list[str] = [project] writer.writerow([time_period, activity_name, note, f"{hours:.2f}"])
if show_period:
row.append(time_period)
row.append(activity_name)
if show_note:
row.append(note or "")
row.append(f"{hours:.2f}")
writer.writerow(row)
# Blank line + total # Blank line + total
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
writer.writerow([]) writer.writerow([])
total_row = [""] * len(header) writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
total_row[0] = strings._("total")
total_row[-1] = f"{total_hours:.2f}"
writer.writerow(total_row)
except OSError as exc: except OSError as exc:
QMessageBox.warning( QMessageBox.warning(
self, self,
@ -1411,20 +1139,17 @@ class TimeReportDialog(QDialog):
if not filename.endswith(".pdf"): if not filename.endswith(".pdf"):
filename = f"{filename}.pdf" filename = f"{filename}.pdf"
# ---------- Build chart image ---------- # ---------- Build chart image (hours per period) ----------
# Default: hours per time period. If grouped by activity: hours per activity. per_period_minutes: dict[str, int] = defaultdict(int)
gran = getattr(self, "_last_gran", "day") for period, _activity, note, minutes in self._last_rows:
per_bucket_minutes: dict[str, int] = defaultdict(int) per_period_minutes[period] += minutes
for _project, period, activity, _note, minutes in self._last_rows:
bucket = activity if gran == "activity" else period
per_bucket_minutes[bucket] += minutes
buckets = sorted(per_bucket_minutes.keys()) periods = sorted(per_period_minutes.keys())
chart_w, chart_h = 800, 220 chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32) chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white) chart.fill(Qt.white)
if buckets: if periods:
painter = QPainter(chart) painter = QPainter(chart)
try: try:
painter.setRenderHint(QPainter.Antialiasing, True) painter.setRenderHint(QPainter.Antialiasing, True)
@ -1452,9 +1177,9 @@ class TimeReportDialog(QDialog):
# Border # Border
painter.drawRect(left, top, width, height) painter.drawRect(left, top, width, height)
max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0 max_hours = max(per_period_minutes[p] for p in periods) / 60.0
if max_hours > 0: if max_hours > 0:
n = len(buckets) n = len(periods)
bar_spacing = width / max(1, n) bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6 bar_width = bar_spacing * 0.6
@ -1479,8 +1204,8 @@ class TimeReportDialog(QDialog):
painter.setBrush(QColor(80, 140, 200)) painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
for i, label in enumerate(buckets): for i, period in enumerate(periods):
hours = per_bucket_minutes[label] / 60.0 hours = per_period_minutes[period] / 60.0
bar_h = int((hours / max_hours) * (height - 10)) bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0: if bar_h <= 0:
continue # pragma: no cover continue # pragma: no cover
@ -1493,7 +1218,7 @@ class TimeReportDialog(QDialog):
# X labels after bars, in black # X labels after bars, in black
painter.setPen(Qt.black) painter.setPen(Qt.black)
for i, label in enumerate(buckets): for i, period in enumerate(periods):
x_center = left + bar_spacing * (i + 0.5) x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2) x = int(x_center - bar_width / 2)
painter.drawText( painter.drawText(
@ -1502,7 +1227,7 @@ class TimeReportDialog(QDialog):
int(bar_width), int(bar_width),
20, 20,
Qt.AlignHCenter | Qt.AlignTop, Qt.AlignHCenter | Qt.AlignTop,
label, period,
) )
finally: finally:
painter.end() painter.end()
@ -1511,29 +1236,16 @@ class TimeReportDialog(QDialog):
project = html.escape(self._last_project_name or "") project = html.escape(self._last_project_name or "")
start = html.escape(self._last_start or "") start = html.escape(self._last_start or "")
end = html.escape(self._last_end or "") end = html.escape(self._last_end or "")
gran_key = getattr(self, "_last_gran", "day") gran = html.escape(self._last_gran_label or "")
gran_label = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
# Table rows # Table rows (period, activity, hours)
row_html_parts: list[str] = [] row_html_parts: list[str] = []
if gran_key == "activity": for period, activity, note, minutes in self._last_rows:
for project, _period, activity, _note, minutes in self._last_rows:
hours = minutes / 60.0 hours = minutes / 60.0
row_html_parts.append( row_html_parts.append(
"<tr>" "<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
else:
for project, period, activity, _note, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(period)}</td>" f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>" f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>" f"<td style='text-align:right'>{hours:.2f}</td>"
@ -1541,24 +1253,6 @@ class TimeReportDialog(QDialog):
) )
rows_html = "\n".join(row_html_parts) rows_html = "\n".join(row_html_parts)
if gran_key == "activity":
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</tr>"
)
else:
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('time_period'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</tr>"
)
html_doc = f""" html_doc = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -1604,11 +1298,15 @@ class TimeReportDialog(QDialog):
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1> <h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
<p class="meta"> <p class="meta">
{html.escape(strings._("time_log_report_meta").format( {html.escape(strings._("time_log_report_meta").format(
start=start, end=end, granularity=gran_label))} start=start, end=end, granularity=gran))}
</p> </p>
<p><img src="chart" class="chart" /></p> <p><img src="chart" class="chart" /></p>
<table> <table>
{table_header_html} <tr>
<th>{html.escape(strings._("time_period"))}</th>
<th>{html.escape(strings._("activity"))}</th>
<th>{html.escape(strings._("hours"))}</th>
</tr>
{rows_html} {rows_html}
</table> </table>
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p> <p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
@ -1635,55 +1333,3 @@ class TimeReportDialog(QDialog):
strings._("export_pdf_error_title"), strings._("export_pdf_error_title"),
strings._("export_pdf_error_message").format(error=str(exc)), strings._("export_pdf_error_message").format(error=str(exc)),
) )
def _update_invoice_button_state(self) -> None:
data = self.project_combo.currentData()
if data is not None:
self.invoice_btn.show()
else:
self.invoice_btn.hide()
def _on_manage_invoices(self) -> None:
from .invoices import InvoicesDialog
dlg = InvoicesDialog(self._db, parent=self)
# When the dialog says "reminders changed", forward that outward
dlg.remindersChanged.connect(self.remindersChanged.emit)
dlg.exec()
def _on_create_invoice(self) -> None:
idx = self.project_combo.currentIndex()
if idx < 0:
return
project_id_data = self.project_combo.itemData(idx)
if project_id_data is None:
# Currently invoices are per-project, not cross-project
QMessageBox.information(
self,
strings._("invoice_project_required_title"),
strings._("invoice_project_required_message"),
)
return
proj_id = int(project_id_data)
# Ensure we have a recent run to base this on
if not self._last_time_logs:
QMessageBox.information(
self,
strings._("invoice_need_report_title"),
strings._("invoice_need_report_message"),
)
return
start = self.from_date.date().toString("yyyy-MM-dd")
end = self.to_date.date().toString("yyyy-MM-dd")
from .invoices import InvoiceDialog
dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self)
dlg.remindersChanged.connect(self.remindersChanged.emit)
dlg.exec()

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtWidgets import QToolBar from PySide6.QtWidgets import QToolBar
from . import strings from . import strings
@ -119,7 +119,6 @@ class ToolBar(QToolBar):
# Focus timer # Focus timer
self.actTimer = QAction("", self) self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.setCheckable(True)
self.actTimer.triggered.connect(self.timerRequested) self.actTimer.triggered.connect(self.timerRequested)
# Documents # Documents

View file

@ -5,17 +5,23 @@ import os
import re import re
import subprocess # nosec import subprocess # nosec
import tempfile import tempfile
from importlib.resources import files
from pathlib import Path from pathlib import Path
import requests import requests
from importlib.resources import files
from PySide6.QtCore import QStandardPaths, Qt from PySide6.QtCore import QStandardPaths, Qt
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtWidgets import (
QApplication,
QMessageBox,
QWidget,
QProgressDialog,
)
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
from PySide6.QtSvg import QSvgRenderer from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
from . import strings
from .settings import APP_NAME from .settings import APP_NAME
from . import strings
# Where to fetch the latest version string from # Where to fetch the latest version string from
VERSION_URL = "https://mig5.net/bouquin/version.txt" VERSION_URL = "https://mig5.net/bouquin/version.txt"

196
poetry.lock generated
View file

@ -146,103 +146,103 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.0" version = "7.12.0"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
{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.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
{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.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
{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.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
{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.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
{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.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
{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.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
{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.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
] ]
[package.dependencies] [package.dependencies]
@ -747,20 +747,20 @@ files = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.2" version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0)"] zstd = ["zstandard (>=0.18.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.7.3" version = "0.6.1"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

View file

@ -2,47 +2,19 @@
set -eo pipefail set -eo pipefail
# Parse the args
while getopts "v:" OPTION
do
case $OPTION in
v)
VERSION=$OPTARG
;;
?)
usage
exit
;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "You forgot to pass -v [version]!"
exit 1
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
set -e
# Clean caches etc # Clean caches etc
filedust -y . /home/user/venv-filedust/bin/filedust -y .
# Publish to Pypi # Publish to Pypi
poetry build poetry build
poetry publish poetry publish
# Make AppImage # Make AppImage
sudo apt-get -y install libfuse-dev sudo apt-get install libfuse-dev
poetry run pyproject-appimage poetry run pyproject-appimage
mv Bouquin.AppImage dist/ mv Bouquin.AppImage dist/
# Sign packages # Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt" echo "Don't forget to update version string on remote server."

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Before After
Before After

View file

@ -106,7 +106,7 @@ def freeze_qt_time(monkeypatch):
QTime.currentTime().addSecs(3600) is still the same calendar day. QTime.currentTime().addSecs(3600) is still the same calendar day.
""" """
import bouquin.main_window as _mwmod import bouquin.main_window as _mwmod
from PySide6.QtCore import QDate, QDateTime, QTime from PySide6.QtCore import QDate, QTime, QDateTime
today = QDate.currentDate() today = QDate.currentDate()
fixed_time = QTime(12, 0) fixed_time = QTime(12, 0)

View file

@ -1,8 +1,8 @@
import bouquin.bug_report_dialog as bugmod import bouquin.bug_report_dialog as bugmod
from bouquin import strings
from bouquin.bug_report_dialog import BugReportDialog from bouquin.bug_report_dialog import BugReportDialog
from PySide6.QtGui import QTextCursor from bouquin import strings
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
def test_bug_report_truncates_text_to_max_chars(qtbot): def test_bug_report_truncates_text_to_max_chars(qtbot):

View file

@ -1,11 +1,13 @@
from PySide6.QtWidgets import QPushButton
from bouquin import strings from bouquin import strings
from PySide6.QtCore import QRect, QSize
from PySide6.QtGui import QPaintEvent, QFont
from bouquin.code_block_editor_dialog import ( from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog, CodeBlockEditorDialog,
CodeEditorWithLineNumbers, CodeEditorWithLineNumbers,
) )
from PySide6.QtCore import QRect, QSize
from PySide6.QtGui import QFont, QPaintEvent
from PySide6.QtWidgets import QPushButton
def _find_button_by_text(widget, text): def _find_button_by_text(widget, text):
@ -157,7 +159,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
rect = QRect(0, 0, line_area.width(), line_area.height()) rect = QRect(0, 0, line_area.width(), line_area.height())
paint_event = QPaintEvent(rect) paint_event = QPaintEvent(rect)
# This should exercise the painting loop # This should exercise the painting loop (lines 87-130)
editor.line_number_area_paint_event(paint_event) editor.line_number_area_paint_event(paint_event)
# Should not crash # Should not crash

View file

@ -1,5 +1,5 @@
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
from PySide6.QtGui import QFont, QTextCharFormat from PySide6.QtGui import QTextCharFormat, QFont
def test_get_language_patterns_python(app): def test_get_language_patterns_python(app):

View file

@ -1,11 +1,9 @@
import csv
import datetime as dt
import json
from datetime import date, timedelta
import pytest import pytest
from bouquin.db import DBManager import json, csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
from datetime import date, timedelta
def _today(): def _today():
@ -373,7 +371,7 @@ def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database.""" """Test gather_stats on empty database."""
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
assert len(stats) == 22 assert len(stats) == 10
( (
pages_with_content, pages_with_content,
total_revisions, total_revisions,
@ -385,18 +383,6 @@ def test_db_gather_stats_empty_database(fresh_db):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = stats ) = stats
assert pages_with_content == 0 assert pages_with_content == 0
@ -433,7 +419,6 @@ def test_db_gather_stats_with_content(fresh_db):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, revisions_by_date,
*_rest,
) = stats ) = stats
assert pages_with_content == 2 assert pages_with_content == 2
@ -450,7 +435,7 @@ def test_db_gather_stats_word_counting(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "test") fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats _, _, _, _, words_by_date, total_words, _, _, _, _ = stats
assert total_words == 5 assert total_words == 5
@ -476,7 +461,7 @@ def test_db_gather_stats_with_tags(fresh_db):
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
assert unique_tags == 3 assert unique_tags == 3
assert page_most_tags == "2024-01-01" assert page_most_tags == "2024-01-01"
@ -492,7 +477,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db):
fresh_db.save_new_version("2024-01-02", "Fourth", "v1") fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats _, _, _, _, _, _, _, _, _, revisions_by_date = stats
assert date(2024, 1, 1) in revisions_by_date assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3 assert revisions_by_date[date(2024, 1, 1)] == 3
@ -507,7 +492,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db):
fresh_db.save_new_version("2024-01-15", "Test", "v1") fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats _, _, _, _, _, _, _, _, _, revisions_by_date = stats
# Should have parsed the date correctly # Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date assert date(2024, 1, 15) in revisions_by_date
@ -520,7 +505,7 @@ def test_db_gather_stats_current_version_only(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2") fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats _, _, _, _, words_by_date, total_words, _, _, _, _ = stats
# Should count words from current version (5 words), not old version # Should count words from current version (5 words), not old version
assert total_words == 5 assert total_words == 5
@ -532,7 +517,7 @@ def test_db_gather_stats_no_tags(fresh_db):
fresh_db.save_new_version("2024-01-01", "No tags here", "test") fresh_db.save_new_version("2024-01-01", "No tags here", "test")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
assert unique_tags == 0 assert unique_tags == 0
assert page_most_tags is None assert page_most_tags is None

View file

@ -1,10 +1,10 @@
import tempfile
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from pathlib import Path
import tempfile
from PySide6.QtCore import QUrl from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QDesktopServices
def test_open_document_from_db_success(qtbot, app, fresh_db): def test_open_document_from_db_success(qtbot, app, fresh_db):

View file

@ -1,12 +1,13 @@
import tempfile from unittest.mock import patch, MagicMock
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch import tempfile
from bouquin.db import DBConfig from bouquin.db import DBConfig
from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget from bouquin.documents import TodaysDocumentsWidget, DocumentsDialog
from PySide6.QtCore import Qt, QUrl from PySide6.QtCore import Qt, QUrl
from PySide6.QtWidgets import QMessageBox, QDialog, QFileDialog
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
# ============================================================================= # =============================================================================
# TodaysDocumentsWidget Tests # TodaysDocumentsWidget Tests

View file

@ -1,9 +1,10 @@
import pytest import pytest
from bouquin.find_bar import FindBar
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget from PySide6.QtWidgets import QTextEdit, QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar
@pytest.fixture @pytest.fixture

View file

@ -1,6 +1,7 @@
from bouquin.history_dialog import HistoryDialog from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from bouquin.history_dialog import HistoryDialog
def test_history_dialog_lists_and_revert(qtbot, fresh_db): def test_history_dialog_lists_and_revert(qtbot, fresh_db):

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog, QLineEdit from PySide6.QtWidgets import QFileDialog, QLineEdit
@ -96,7 +97,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
"""Test KeyPrompt with show_db_change but no initial_db_path""" """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
prompt = KeyPrompt(show_db_change=True, initial_db_path=None) prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
qtbot.addWidget(prompt) qtbot.addWidget(prompt)
@ -167,7 +168,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
"""Test browsing when initial_db_path is set""" """Test browsing when initial_db_path is set - covers line 57 with non-None path"""
initial_db = tmp_path / "initial.db" initial_db = tmp_path / "initial.db"
initial_db.touch() initial_db.touch()
@ -179,7 +180,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
# Mock the file dialog to return a different file # Mock the file dialog to return a different file
def mock_get_open_filename(*args, **kwargs): def mock_get_open_filename(*args, **kwargs):
# Verify that start_dir was passed correctly # Verify that start_dir was passed correctly (line 57)
return str(new_db), "SQLCipher DB (*.db)" return str(new_db), "SQLCipher DB (*.db)"
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)

View file

@ -1,7 +1,7 @@
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QEvent from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_lock_overlay_reacts_to_theme(app, qtbot): def test_lock_overlay_reacts_to_theme(app, qtbot):

View file

@ -1,6 +1,5 @@
import importlib import importlib
import runpy import runpy
import pytest import pytest

View file

@ -1,19 +1,22 @@
import pytest
import importlib.metadata import importlib.metadata
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch
import bouquin.main_window as mwmod import bouquin.main_window as mwmod
import bouquin.version_check as version_check
import pytest
from bouquin.db import DBConfig, DBManager
from bouquin.key_prompt import KeyPrompt
from bouquin.main_window import MainWindow from bouquin.main_window import MainWindow
from bouquin.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer from bouquin.settings import get_settings
from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor from bouquin.key_prompt import KeyPrompt
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget from bouquin.db import DBConfig, DBManager
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
from unittest.mock import Mock, patch
import bouquin.version_check as version_check
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
@ -2226,7 +2229,7 @@ def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
assert window.tab_widget.count() == initial_count - 1 assert window.tab_widget.count() == initial_count - 1
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
"""Test parsing inline alarms from markdown (⏰ HH:MM format).""" """Test parsing inline alarms from markdown (⏰ HH:MM format)."""
from PySide6.QtCore import QTime from PySide6.QtCore import QTime
@ -2252,7 +2255,7 @@ def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
window._open_date_in_tab(today_qdate) window._open_date_in_tab(today_qdate)
# Set content with a future alarm # Set content with a future alarm
future_time = QTime.currentTime().addSecs(3600) future_time = QTime.currentTime().addSecs(3600) # 1 hour from now
alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}" alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
# Set the editor's current_date attribute # Set the editor's current_date attribute
@ -2308,7 +2311,7 @@ def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
assert len(window._reminder_timers) == 0 assert len(window._reminder_timers) == 0
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
"""Test that past alarms are skipped.""" """Test that past alarms are skipped."""
from PySide6.QtCore import QTime from PySide6.QtCore import QTime
@ -2350,7 +2353,7 @@ def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_q
assert len(window._reminder_timers) == 0 assert len(window._reminder_timers) == 0
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time): def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db):
"""Test alarm with no text before emoji uses fallback.""" """Test alarm with no text before emoji uses fallback."""
from PySide6.QtCore import QTime from PySide6.QtCore import QTime

View file

@ -1,21 +1,22 @@
import base64 import base64
import pytest import pytest
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor,
QFont,
QImage, QImage,
QColor,
QKeyEvent, QKeyEvent,
QTextCharFormat,
QTextCursor, QTextCursor,
QTextDocument, QTextDocument,
QFont,
QTextCharFormat,
) )
from PySide6.QtWidgets import QApplication, QTextEdit from PySide6.QtWidgets import QApplication, QTextEdit
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def _today(): def _today():
from datetime import date from datetime import date
@ -1927,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app):
def test_markdown_highlighter_dark_theme(qtbot, app): def test_markdown_highlighter_dark_theme(qtbot, app):
"""Test markdown highlighter with dark theme""" """Test markdown highlighter with dark theme - covers lines 74-75"""
# Create theme manager with dark theme # Create theme manager with dark theme
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
@ -2292,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
# Force rehighlight # Force rehighlight
editor.highlighter.rehighlight() editor.highlighter.rehighlight()
# Verify syntax highlighting was applied # Verify syntax highlighting was applied (lines 186-193)
# We can't easily verify the exact formatting, but we ensure no crash # We can't easily verify the exact formatting, but we ensure no crash
@ -2304,10 +2305,13 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
# Force rehighlight # Force rehighlight
editor.highlighter.rehighlight() editor.highlighter.rehighlight()
# The overlap detection (lines 252, 264) should prevent issues
def test_highlighter_italic_edge_cases(editor, qtbot): def test_highlighter_italic_edge_cases(editor, qtbot):
"""Test italic formatting edge cases.""" """Test italic formatting edge cases."""
# Test edge case: avoiding stealing markers that are part of double # Test edge case: avoiding stealing markers that are part of double
# This tests lines 267-270
editor.setPlainText("**not italic* text**") editor.setPlainText("**not italic* text**")
# Force rehighlight # Force rehighlight

View file

@ -4,18 +4,19 @@ These tests should be added to test_markdown_editor.py.
""" """
import pytest import pytest
from bouquin.markdown_editor import MarkdownEditor from PySide6.QtCore import Qt, QPoint
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor,
QImage, QImage,
QColor,
QKeyEvent, QKeyEvent,
QMouseEvent,
QTextCursor, QTextCursor,
QTextDocument, QTextDocument,
QMouseEvent,
) )
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def text(editor) -> str: def text(editor) -> str:
return editor.toPlainText() return editor.toPlainText()
@ -43,6 +44,7 @@ def editor(app, qtbot):
return ed return ed
# Test for line 215: document is None guard
def test_update_code_block_backgrounds_with_no_document(app, qtbot): def test_update_code_block_backgrounds_with_no_document(app, qtbot):
"""Test _update_code_block_row_backgrounds when document is None.""" """Test _update_code_block_row_backgrounds when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -58,6 +60,7 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot):
editor._update_code_block_row_backgrounds() editor._update_code_block_row_backgrounds()
# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases
def test_find_code_block_bounds_invalid_block(editor): def test_find_code_block_bounds_invalid_block(editor):
"""Test _find_code_block_bounds with invalid block.""" """Test _find_code_block_bounds with invalid block."""
editor.setPlainText("some text") editor.setPlainText("some text")
@ -121,6 +124,7 @@ def test_find_code_block_bounds_no_opening_fence(editor):
assert result is None assert result is None
# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases
def test_edit_code_block_checks_document(app, qtbot): def test_edit_code_block_checks_document(app, qtbot):
"""Test _edit_code_block when editor has no document.""" """Test _edit_code_block when editor has no document."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -144,8 +148,8 @@ def test_edit_code_block_checks_document(app, qtbot):
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch): def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
"""Test _edit_code_block when dialog is cancelled.""" """Test _edit_code_block when dialog is cancelled."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
class CancelledDialog: class CancelledDialog:
def __init__(self, code, language, parent=None, allow_delete=False): def __init__(self, code, language, parent=None, allow_delete=False):
@ -174,8 +178,8 @@ def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch): def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
"""Test _edit_code_block when user deletes the block.""" """Test _edit_code_block when user deletes the block."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
class DeleteDialog: class DeleteDialog:
def __init__(self, code, language, parent=None, allow_delete=False): def __init__(self, code, language, parent=None, allow_delete=False):
@ -213,8 +217,8 @@ def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
def test_edit_code_block_language_change(editor, qtbot, monkeypatch): def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
"""Test _edit_code_block with language change.""" """Test _edit_code_block with language change."""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog from PySide6.QtWidgets import QDialog
import bouquin.markdown_editor as markdown_editor
class LanguageChangeDialog: class LanguageChangeDialog:
def __init__(self, code, language, parent=None, allow_delete=False): def __init__(self, code, language, parent=None, allow_delete=False):
@ -245,6 +249,7 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
assert lang == "javascript" assert lang == "javascript"
# Test for lines 443-490: _delete_code_block
def test_delete_code_block_no_bounds(editor): def test_delete_code_block_no_bounds(editor):
"""Test _delete_code_block when bounds can't be found.""" """Test _delete_code_block when bounds can't be found."""
editor.setPlainText("not a code block") editor.setPlainText("not a code block")
@ -302,6 +307,7 @@ def test_delete_code_block_with_text_after(editor):
assert "text after" in new_text assert "text after" in new_text
# Test for line 496: _apply_line_spacing with no document
def test_apply_line_spacing_no_document(app): def test_apply_line_spacing_no_document(app):
"""Test _apply_line_spacing when document is None.""" """Test _apply_line_spacing when document is None."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -313,6 +319,7 @@ def test_apply_line_spacing_no_document(app):
editor._apply_line_spacing(125.0) editor._apply_line_spacing(125.0)
# Test for line 517: _apply_code_block_spacing
def test_apply_code_block_spacing(editor): def test_apply_code_block_spacing(editor):
"""Test _apply_code_block_spacing applies correct spacing.""" """Test _apply_code_block_spacing applies correct spacing."""
editor.setPlainText("```\nline1\nline2\n```") editor.setPlainText("```\nline1\nline2\n```")
@ -327,6 +334,7 @@ def test_apply_code_block_spacing(editor):
assert block.isValid() assert block.isValid()
# Test for line 604: to_markdown with metadata
def test_to_markdown_with_code_metadata(editor): def test_to_markdown_with_code_metadata(editor):
"""Test to_markdown includes code block metadata.""" """Test to_markdown includes code block metadata."""
editor.setPlainText("```python\ncode\n```") editor.setPlainText("```python\ncode\n```")
@ -340,6 +348,7 @@ def test_to_markdown_with_code_metadata(editor):
assert "code-langs" in md or "code" in md assert "code-langs" in md or "code" in md
# Test for line 648: from_markdown without _code_metadata attribute
def test_from_markdown_creates_code_metadata(app): def test_from_markdown_creates_code_metadata(app):
"""Test from_markdown creates _code_metadata if missing.""" """Test from_markdown creates _code_metadata if missing."""
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -355,6 +364,7 @@ def test_from_markdown_creates_code_metadata(app):
assert hasattr(editor, "_code_metadata") assert hasattr(editor, "_code_metadata")
# Test for lines 718-736: image embedding with original size
def test_embed_images_preserves_original_size(editor, tmp_path): def test_embed_images_preserves_original_size(editor, tmp_path):
"""Test that embedded images preserve their original dimensions.""" """Test that embedded images preserve their original dimensions."""
# Create a test image # Create a test image
@ -377,6 +387,7 @@ def test_embed_images_preserves_original_size(editor, tmp_path):
assert doc is not None assert doc is not None
# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection
def test_trim_list_prefix_no_selection(editor): def test_trim_list_prefix_no_selection(editor):
"""Test _maybe_trim_list_prefix_from_line_selection with no selection.""" """Test _maybe_trim_list_prefix_from_line_selection with no selection."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -436,6 +447,7 @@ def test_trim_list_prefix_during_adjustment(editor):
editor._adjusting_selection = False editor._adjusting_selection = False
# Test for lines 848, 860-866: _detect_list_type
def test_detect_list_type_checkbox_checked(editor): def test_detect_list_type_checkbox_checked(editor):
"""Test _detect_list_type with checked checkbox.""" """Test _detect_list_type with checked checkbox."""
list_type, prefix = editor._detect_list_type( list_type, prefix = editor._detect_list_type(
@ -466,6 +478,7 @@ def test_detect_list_type_not_a_list(editor):
assert prefix == "" assert prefix == ""
# Test for lines 876, 884-886: list prefix length calculation
def test_list_prefix_length_numbered(editor): def test_list_prefix_length_numbered(editor):
"""Test _list_prefix_length_for_block with numbered list.""" """Test _list_prefix_length_for_block with numbered list."""
editor.setPlainText("123. item") editor.setPlainText("123. item")
@ -476,6 +489,7 @@ def test_list_prefix_length_numbered(editor):
assert length > 0 assert length > 0
# Test for lines 948-949: keyPressEvent with Ctrl+Home
def test_key_press_ctrl_home(editor, qtbot): def test_key_press_ctrl_home(editor, qtbot):
"""Test Ctrl+Home key combination.""" """Test Ctrl+Home key combination."""
editor.setPlainText("line1\nline2\nline3") editor.setPlainText("line1\nline2\nline3")
@ -490,6 +504,7 @@ def test_key_press_ctrl_home(editor, qtbot):
assert editor.textCursor().position() == 0 assert editor.textCursor().position() == 0
# Test for lines 957-960: keyPressEvent with Ctrl+Left
def test_key_press_ctrl_left(editor, qtbot): def test_key_press_ctrl_left(editor, qtbot):
"""Test Ctrl+Left key combination.""" """Test Ctrl+Left key combination."""
editor.setPlainText("word1 word2 word3") editor.setPlainText("word1 word2 word3")
@ -503,6 +518,7 @@ def test_key_press_ctrl_left(editor, qtbot):
# Should move left by word # Should move left by word
# Test for lines 984-988, 1044: Home key in list
def test_key_press_home_in_list(editor, qtbot): def test_key_press_home_in_list(editor, qtbot):
"""Test Home key in list item.""" """Test Home key in list item."""
editor.setPlainText("- item text") editor.setPlainText("- item text")
@ -518,6 +534,7 @@ def test_key_press_home_in_list(editor, qtbot):
assert pos > 0 assert pos > 0
# Test for lines 1067-1073: Left key in list prefix
def test_key_press_left_in_list_prefix(editor, qtbot): def test_key_press_left_in_list_prefix(editor, qtbot):
"""Test Left key when in list prefix region.""" """Test Left key when in list prefix region."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -532,6 +549,7 @@ def test_key_press_left_in_list_prefix(editor, qtbot):
# Should snap to after prefix # Should snap to after prefix
# Test for lines 1088, 1095-1104: Up/Down in code blocks
def test_key_press_up_in_code_block(editor, qtbot): def test_key_press_up_in_code_block(editor, qtbot):
"""Test Up key inside code block.""" """Test Up key inside code block."""
editor.setPlainText("```\ncode line 1\ncode line 2\n```") editor.setPlainText("```\ncode line 1\ncode line 2\n```")
@ -561,6 +579,7 @@ def test_key_press_down_in_list_item(editor, qtbot):
# Should snap to after prefix on next line # Should snap to after prefix on next line
# Test for lines 1127-1130, 1134-1137: Enter key with markers
def test_key_press_enter_after_markers(editor, qtbot): def test_key_press_enter_after_markers(editor, qtbot):
"""Test Enter key after style markers.""" """Test Enter key after style markers."""
editor.setPlainText("text **") editor.setPlainText("text **")
@ -574,6 +593,7 @@ def test_key_press_enter_after_markers(editor, qtbot):
# Should handle markers # Should handle markers
# Test for lines 1146-1164: Enter on fence line
def test_key_press_enter_on_closing_fence(editor, qtbot): def test_key_press_enter_on_closing_fence(editor, qtbot):
"""Test Enter key on closing fence line.""" """Test Enter key on closing fence line."""
editor.setPlainText("```\ncode\n```") editor.setPlainText("```\ncode\n```")
@ -588,6 +608,7 @@ def test_key_press_enter_on_closing_fence(editor, qtbot):
# Should create new line after fence # Should create new line after fence
# Test for lines 1185-1189: Backspace in empty checkbox
def test_key_press_backspace_empty_checkbox(editor, qtbot): def test_key_press_backspace_empty_checkbox(editor, qtbot):
"""Test Backspace in empty checkbox item.""" """Test Backspace in empty checkbox item."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
@ -601,6 +622,7 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot):
# Should remove checkbox # Should remove checkbox
# Test for lines 1205, 1215-1221: Backspace in numbered list
def test_key_press_backspace_numbered_list(editor, qtbot): def test_key_press_backspace_numbered_list(editor, qtbot):
"""Test Backspace at start of numbered list item.""" """Test Backspace at start of numbered list item."""
editor.setPlainText("1. ") editor.setPlainText("1. ")
@ -612,6 +634,7 @@ def test_key_press_backspace_numbered_list(editor, qtbot):
editor.keyPressEvent(event) editor.keyPressEvent(event)
# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists
def test_key_press_tab_in_bullet_list(editor, qtbot): def test_key_press_tab_in_bullet_list(editor, qtbot):
"""Test Tab key in bullet list.""" """Test Tab key in bullet list."""
editor.setPlainText("- item") editor.setPlainText("- item")
@ -649,6 +672,7 @@ def test_key_press_tab_in_checkbox(editor, qtbot):
editor.keyPressEvent(event) editor.keyPressEvent(event)
# Test for lines 1282-1283: Auto-pairing skip
def test_apply_weight_to_selection(editor, qtbot): def test_apply_weight_to_selection(editor, qtbot):
"""Test apply_weight makes text bold.""" """Test apply_weight makes text bold."""
editor.setPlainText("text to bold") editor.setPlainText("text to bold")
@ -688,6 +712,7 @@ def test_apply_strikethrough_to_selection(editor, qtbot):
assert "~~" in md assert "~~" in md
# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks
def test_apply_code_on_selection(editor, qtbot): def test_apply_code_on_selection(editor, qtbot):
"""Test apply_code with selected text.""" """Test apply_code with selected text."""
editor.setPlainText("some code") editor.setPlainText("some code")
@ -703,6 +728,7 @@ def test_apply_code_on_selection(editor, qtbot):
# May contain code block elements depending on dialog behavior # May contain code block elements depending on dialog behavior
# Test for line 1386: toggle_numbers
def test_toggle_numbers_on_plain_text(editor, qtbot): def test_toggle_numbers_on_plain_text(editor, qtbot):
"""Test toggle_numbers converts text to numbered list.""" """Test toggle_numbers converts text to numbered list."""
editor.setPlainText("item 1") editor.setPlainText("item 1")
@ -716,6 +742,7 @@ def test_toggle_numbers_on_plain_text(editor, qtbot):
assert "1." in text assert "1." in text
# Test for lines 1402-1407: toggle_bullets
def test_toggle_bullets_on_plain_text(editor, qtbot): def test_toggle_bullets_on_plain_text(editor, qtbot):
"""Test toggle_bullets converts text to bullet list.""" """Test toggle_bullets converts text to bullet list."""
editor.setPlainText("item 1") editor.setPlainText("item 1")
@ -744,6 +771,7 @@ def test_toggle_bullets_removes_bullets(editor, qtbot):
assert text.strip() == "item 1" assert text.strip() == "item 1"
# Test for line 1429: toggle_checkboxes
def test_toggle_checkboxes_on_bullets(editor, qtbot): def test_toggle_checkboxes_on_bullets(editor, qtbot):
"""Test toggle_checkboxes converts bullets to checkboxes.""" """Test toggle_checkboxes converts bullets to checkboxes."""
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
@ -758,6 +786,7 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text assert editor._CHECK_UNCHECKED_DISPLAY in text
# Test for line 1452: apply_heading
def test_apply_heading_various_levels(editor, qtbot): def test_apply_heading_various_levels(editor, qtbot):
"""Test apply_heading with different levels.""" """Test apply_heading with different levels."""
test_cases = [ test_cases = [
@ -780,6 +809,7 @@ def test_apply_heading_various_levels(editor, qtbot):
assert text.startswith(expected_marker) assert text.startswith(expected_marker)
# Test for lines 1501-1505: insert_image_from_path
def test_insert_image_from_path_invalid_extension(editor, tmp_path): def test_insert_image_from_path_invalid_extension(editor, tmp_path):
"""Test insert_image_from_path with invalid extension.""" """Test insert_image_from_path with invalid extension."""
invalid_file = tmp_path / "file.txt" invalid_file = tmp_path / "file.txt"
@ -797,6 +827,7 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path):
editor.insert_image_from_path(nonexistent) editor.insert_image_from_path(nonexistent)
# Test for lines 1578-1579: mousePressEvent checkbox toggle
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot): def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
"""Test clicking checkbox toggles it from unchecked to checked.""" """Test clicking checkbox toggles it from unchecked to checked."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@ -841,6 +872,7 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
assert editor._CHECK_UNCHECKED_DISPLAY in text assert editor._CHECK_UNCHECKED_DISPLAY in text
# Test for line 1602: mouseDoubleClickEvent
def test_mouse_double_click_suppression(editor, qtbot): def test_mouse_double_click_suppression(editor, qtbot):
"""Test double-click suppression for checkboxes.""" """Test double-click suppression for checkboxes."""
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
@ -863,6 +895,7 @@ def test_mouse_double_click_suppression(editor, qtbot):
assert not editor._suppress_next_checkbox_double_click assert not editor._suppress_next_checkbox_double_click
# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling)
def test_context_menu_in_code_block(editor, qtbot): def test_context_menu_in_code_block(editor, qtbot):
"""Test context menu when in code block.""" """Test context menu when in code block."""
editor.setPlainText("```python\ncode\n```") editor.setPlainText("```python\ncode\n```")
@ -882,6 +915,7 @@ def test_context_menu_in_code_block(editor, qtbot):
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash # Note: actual menu exec is blocked in tests, but we verify it doesn't crash
# Test for lines 1742-1757: _set_code_block_language
def test_set_code_block_language(editor, qtbot): def test_set_code_block_language(editor, qtbot):
"""Test _set_code_block_language sets metadata.""" """Test _set_code_block_language sets metadata."""
editor.setPlainText("```\ncode\n```") editor.setPlainText("```\ncode\n```")
@ -895,6 +929,7 @@ def test_set_code_block_language(editor, qtbot):
assert lang == "python" assert lang == "python"
# Test for lines 1770-1783: get_current_line_task_text
def test_get_current_line_task_text_strips_prefixes(editor, qtbot): def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
"""Test get_current_line_task_text removes list/checkbox prefixes.""" """Test get_current_line_task_text removes list/checkbox prefixes."""
test_cases = [ test_cases = [

View file

@ -1,55 +1,6 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
class DummyTimeLogWidget(QWidget):
"""Minimal stand-in for the real TimeLogWidget used by PomodoroManager."""
def __init__(self, parent=None):
super().__init__(parent)
self.layout = QVBoxLayout(self)
self.summary_label = QLabel(self)
# toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped
self.toggle_btn = Mock()
self.toggle_btn.isChecked.return_value = True
def show_pomodoro_widget(self, widget):
# Manager calls this when embedding the timer
if widget is not None:
self.layout.addWidget(widget)
def clear_pomodoro_widget(self):
# Manager calls this when removing the embedded timer
while self.layout.count():
item = self.layout.takeAt(0)
w = item.widget()
if w is not None:
w.setParent(None)
def _reload_summary(self):
# Called after TimeLogDialog closes; no-op is fine for tests
pass
class DummyMainWindow(QWidget):
"""Minimal stand-in for MainWindow that PomodoroManager expects."""
def __init__(self, app, parent=None):
super().__init__(parent)
# Sidebar time log widget
self.time_log = DummyTimeLogWidget(self)
# Toolbar with an actTimer QAction so QSignalBlocker works
self.toolBar = QToolBar(self)
self.toolBar.actTimer = QAction(self)
self.toolBar.addAction(self.toolBar.actTimer)
# Themes attribute used when constructing TimeLogDialog
self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def test_pomodoro_timer_init(qtbot, app, fresh_db): def test_pomodoro_timer_init(qtbot, app, fresh_db):
@ -197,6 +148,15 @@ def test_pomodoro_timer_modal_state(qtbot, app):
assert timer.isModal() is False assert timer.isModal() is False
def test_pomodoro_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db): def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization.""" """Test PomodoroManager initialization."""
parent = Mock() parent = Mock()
@ -209,10 +169,10 @@ def test_pomodoro_manager_init(app, fresh_db):
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager.""" """Test starting a timer through the manager."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
line_text = "Important task" line_text = "Important task"
@ -222,16 +182,15 @@ def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
assert manager._active_timer is not None assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text assert manager._active_timer._task_text == line_text
# Timer should be embedded in the sidebar time log widget qtbot.addWidget(manager._active_timer)
assert manager._active_timer.parent() is parent.time_log
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes/replaces the previous one.""" """Test that starting a new timer closes the previous one."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Start first timer # Start first timer
@ -247,20 +206,16 @@ def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
assert first_timer is not second_timer assert first_timer is not second_timer
assert second_timer._task_text == "Task 2" assert second_timer._task_text == "Task 2"
assert second_timer.parent() is parent.time_log
def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Timer stopped with very short time logs should enforce minimum hours.""" """Test that timer stopped with very short time logs minimum hours."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid showing it # Mock TimeLogDialog to avoid actually showing it
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.hours_spin = Mock() mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock() mock_dialog.note = Mock()
@ -276,11 +231,8 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): 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.""" """Test that elapsed time is properly rounded to decimal hours."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -289,25 +241,21 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
mock_dialog.exec = Mock() mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# 1800 seconds (30 min) should round up to 0.5 # Test with 1800 seconds (30 minutes)
manager._on_timer_stopped(1800, "Task", "2024-01-15") manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once() mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0 assert hours_set > 0
# Should be a multiple of 0.25 assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
assert hours_set * 4 == int(hours_set * 4)
def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Timer stopped should pre-fill the note in the time log dialog.""" """Test that timer stopped pre-fills the note in time log dialog."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -326,11 +274,12 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note(
def test_pomodoro_manager_timer_stopped_signal_connection( def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Timer's stop button should result in TimeLogDialog being executed.""" """Test that timer stopped signal is properly connected."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog # Mock TimeLogDialog
@ -344,12 +293,11 @@ def test_pomodoro_manager_timer_stopped_signal_connection(
timer = manager._active_timer timer = manager._active_timer
qtbot.addWidget(timer) qtbot.addWidget(timer)
# Simulate timer having run for a bit # Simulate timer stopped
timer._elapsed_seconds = 1000 timer._elapsed_seconds = 1000
# Clicking "Stop and log" should emit timerStopped and open the dialog
timer._stop_and_log() timer._stop_and_log()
# TimeLogDialog should have been created
assert mock_dialog.exec.called assert mock_dialog.exec.called

View file

@ -1,17 +1,18 @@
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest import pytest
from unittest.mock import patch, MagicMock
from bouquin.reminders import ( from bouquin.reminders import (
ManageRemindersDialog,
Reminder, Reminder,
ReminderDialog,
ReminderType, ReminderType,
ReminderDialog,
UpcomingRemindersWidget, UpcomingRemindersWidget,
ManageRemindersDialog,
) )
from PySide6.QtCore import QDate, QDateTime, QTime from PySide6.QtCore import QDateTime, QDate, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
from datetime import date, timedelta
@pytest.fixture @pytest.fixture
def freeze_reminders_time(monkeypatch): def freeze_reminders_time(monkeypatch):
@ -414,6 +415,17 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
widget._check_reminders() widget._check_reminders()
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db): def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization.""" """Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db) dialog = ManageRemindersDialog(fresh_db)
@ -575,7 +587,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
# Check that the type column shows the day # Check that the type column shows the day
type_item = dialog.table.item(0, 3) type_item = dialog.table.item(0, 2)
assert "Wed" in type_item.text() assert "Wed" in type_item.text()
@ -839,9 +851,9 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
def test_upcoming_reminders_context_menu_shows( def test_upcoming_reminders_context_menu_shows(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
): ):
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget from PySide6 import QtWidgets, QtGui
from PySide6 import QtGui, QtWidgets
from PySide6.QtCore import QPoint from PySide6.QtCore import QPoint
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
# Add a future reminder for today # Add a future reminder for today
r = Reminder( r = Reminder(
@ -897,9 +909,9 @@ def test_upcoming_reminders_context_menu_shows(
def test_upcoming_reminders_delete_selected_dedupes( def test_upcoming_reminders_delete_selected_dedupes(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
): ):
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
from PySide6.QtCore import QItemSelectionModel
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QItemSelectionModel
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
r = Reminder( r = Reminder(
id=None, id=None,

View file

@ -1,5 +1,9 @@
from bouquin.settings import (
get_settings,
load_db_config,
save_db_config,
)
from bouquin.db import DBConfig from bouquin.db import DBConfig
from bouquin.settings import get_settings, load_db_config, save_db_config
def _clear_db_settings(): def _clear_db_settings():

View file

@ -1,11 +1,11 @@
import bouquin.settings_dialog as sd from bouquin.db import DBManager, DBConfig
from bouquin.db import DBConfig, DBManager
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
from bouquin.settings import get_settings import bouquin.settings_dialog as sd
from bouquin.settings_dialog import SettingsDialog from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):

View file

@ -1,11 +1,13 @@
import datetime as _dt import datetime as _dt
from datetime import date, datetime, timedelta from datetime import datetime, timedelta, date
from bouquin import strings from bouquin import strings
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
from PySide6.QtCore import QDate, QPoint, Qt from PySide6.QtCore import Qt, QPoint, QDate
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QLabel, QWidget from PySide6.QtWidgets import QLabel, QWidget
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
class FakeStatsDB: class FakeStatsDB:
@ -14,7 +16,6 @@ class FakeStatsDB:
def __init__(self): def __init__(self):
d1 = _dt.date(2024, 1, 1) d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2) d2 = _dt.date(2024, 1, 2)
self.stats = ( self.stats = (
2, # pages_with_content 2, # pages_with_content
5, # total_revisions 5, # total_revisions
@ -26,20 +27,7 @@ class FakeStatsDB:
"2024-01-02", # page_most_tags "2024-01-02", # page_most_tags
2, # page_most_tags_count 2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date {d1: 1, d2: 2}, # revisions_by_date
{d1: 60, d2: 120}, # time_minutes_by_date
180, # total_time_minutes
"2024-01-02", # day_most_time
120, # day_most_time_minutes
"Project A", # project_most_minutes_name
120, # project_most_minutes
"Activity A", # activity_most_minutes_name
120, # activity_most_minutes
{d1: 1, d2: 3}, # reminders_by_date
4, # total_reminders
"2024-01-02", # day_most_reminders
3, # day_most_reminders_count
) )
self.called = False self.called = False
def gather_stats(self): def gather_stats(self):
@ -71,7 +59,7 @@ def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Heatmap is created and uses "words" by default # Heatmap is created and uses "words" by default
words_by_date = db.stats[4] words_by_date = db.stats[4]
revisions_by_date = db.stats[9] revisions_by_date = db.stats[-1]
assert hasattr(dlg, "_heatmap") assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date assert dlg._heatmap._data == words_by_date
@ -94,25 +82,13 @@ class EmptyStatsDB:
0, # pages_with_content 0, # pages_with_content
0, # total_revisions 0, # total_revisions
None, # page_most_revisions None, # page_most_revisions
0, # page_most_revisions_count 0,
{}, # words_by_date {}, # words_by_date
0, # total_words 0, # total_words
0, # unique_tags 0, # unique_tags
None, # page_most_tags None, # page_most_tags
0, # page_most_tags_count 0,
{}, # revisions_by_date {}, # revisions_by_date
{}, # time_minutes_by_date
0, # total_time_minutes
None, # day_most_time
0, # day_most_time_minutes
None, # project_most_minutes_name
0, # project_most_minutes
None, # activity_most_minutes_name
0, # activity_most_minutes
{}, # reminders_by_date
0, # total_reminders
None, # day_most_reminders
0, # day_most_reminders_count
) )
@ -656,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
# Force a repaint to execute paintEvent # Force a repaint to execute paintEvent
heatmap.repaint() heatmap.repaint()
# The month continuation logic should prevent duplicate labels # The month continuation logic (line 175) should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash # We can't easily test the visual output, but we ensure no crash

View file

@ -1,11 +1,12 @@
import types import types
from bouquin.history_dialog import HistoryDialog
from bouquin.main_window import MainWindow
from bouquin.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QFileDialog from PySide6.QtWidgets import QFileDialog
from PySide6.QtGui import QTextCursor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):

View file

@ -1,21 +1,24 @@
import bouquin.strings as strings
import pytest import pytest
from bouquin.db import DBManager
from bouquin.flow_layout import FlowLayout from PySide6.QtCore import Qt, QPoint, QEvent, QDate
from bouquin.strings import load_strings from PySide6.QtGui import QMouseEvent, QColor
from bouquin.tag_browser import TagBrowserDialog
from bouquin.tags_widget import PageTagsWidget, TagChip
from PySide6.QtCore import QDate, QEvent, QPoint, Qt
from PySide6.QtGui import QColor, QMouseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QMessageBox,
QInputDialog,
QColorDialog, QColorDialog,
QDialog, QDialog,
QInputDialog,
QMessageBox,
) )
from bouquin.db import DBManager
from bouquin.strings import load_strings
from bouquin.tags_widget import PageTagsWidget, TagChip
from bouquin.tag_browser import TagBrowserDialog
from bouquin.flow_layout import FlowLayout
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
import bouquin.strings as strings
# ============================================================================ # ============================================================================
# DB Layer Tag Tests # DB Layer Tag Tests
# ============================================================================ # ============================================================================
@ -1646,7 +1649,7 @@ def test_default_tag_colour_none(fresh_db):
def test_flow_layout_take_at_invalid_index(app): def test_flow_layout_take_at_invalid_index(app):
"""Test FlowLayout.takeAt with out-of-bounds index""" """Test FlowLayout.takeAt with out-of-bounds index"""
from PySide6.QtWidgets import QLabel, QWidget from PySide6.QtWidgets import QWidget, QLabel
widget = QWidget() widget = QWidget()
layout = FlowLayout(widget) layout = FlowLayout(widget)
@ -1670,7 +1673,7 @@ def test_flow_layout_take_at_invalid_index(app):
def test_flow_layout_take_at_boundary(app): def test_flow_layout_take_at_boundary(app):
"""Test FlowLayout.takeAt at exact boundary""" """Test FlowLayout.takeAt at exact boundary"""
from PySide6.QtWidgets import QLabel, QWidget from PySide6.QtWidgets import QWidget, QLabel
widget = QWidget() widget = QWidget()
layout = FlowLayout(widget) layout = FlowLayout(widget)

View file

@ -1,7 +1,8 @@
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QPalette from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from bouquin.theme import Theme, ThemeConfig, ThemeManager
def test_theme_manager_apply_light_and_dark(app): def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT) cfg = ThemeConfig(theme=Theme.LIGHT)

View file

@ -1,18 +1,24 @@
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import bouquin.strings as strings
import pytest import pytest
from bouquin.theme import Theme, ThemeConfig, ThemeManager from datetime import date, timedelta
from PySide6.QtCore import Qt, QDate
from PySide6.QtWidgets import (
QMessageBox,
QInputDialog,
QFileDialog,
QDialog,
)
from sqlcipher3.dbapi2 import IntegrityError
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.time_log import ( from bouquin.time_log import (
TimeCodeManagerDialog,
TimeLogDialog,
TimeLogWidget, TimeLogWidget,
TimeLogDialog,
TimeCodeManagerDialog,
TimeReportDialog, TimeReportDialog,
) )
from PySide6.QtCore import QDate, Qt import bouquin.strings as strings
from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
from sqlcipher3.dbapi2 import IntegrityError from unittest.mock import patch, MagicMock
@pytest.fixture @pytest.fixture
@ -1184,8 +1190,8 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 1 assert dialog.project_combo.count() == 0
assert dialog.granularity.count() == 5 assert dialog.granularity.count() == 3 # day, week, month
def test_time_report_dialog_loads_projects(qtbot, fresh_db): def test_time_report_dialog_loads_projects(qtbot, fresh_db):
@ -1196,18 +1202,18 @@ def test_time_report_dialog_loads_projects(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 3 assert dialog.project_combo.count() == 2
def test_time_report_dialog_default_date_range(qtbot, fresh_db): def test_time_report_dialog_default_date_range(qtbot, fresh_db):
"""Dialog defaults to start of month.""" """Dialog defaults to last 7 days."""
dialog = TimeReportDialog(fresh_db) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
today = QDate.currentDate() today = QDate.currentDate()
start_of_month = QDate(today.year(), today.month(), 1) week_ago = today.addDays(-7)
assert dialog.from_date.date() == start_of_month assert dialog.from_date.date() == week_ago
assert dialog.to_date.date() == today assert dialog.to_date.date() == today
@ -1224,14 +1230,12 @@ def test_time_report_dialog_run_report(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0) dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
idx_day = dialog.granularity.findData("day") dialog.granularity.setCurrentIndex(0) # day
assert idx_day != -1
dialog.granularity.setCurrentIndex(idx_day)
dialog._run_report() dialog._run_report()
assert dialog.table.rowCount() == 1 assert dialog.table.rowCount() == 1
assert "Activity" in dialog.table.item(0, 2).text() assert "Activity" in dialog.table.item(0, 1).text()
assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text() assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text()
@ -1413,18 +1417,13 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0) dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
idx_week = dialog.granularity.findData("week")
assert idx_week != -1
dialog.granularity.setCurrentIndex(idx_week)
dialog._run_report() dialog._run_report()
# Should aggregate to single week # Should aggregate to single week
assert dialog.table.rowCount() == 1 assert dialog.table.rowCount() == 1
# In grouped modes the Note column is hidden → hours are in column 3
hours_text = dialog.table.item(0, 3).text() hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text assert "2.5" in hours_text or "2.50" in hours_text
@ -1446,17 +1445,13 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db):
dialog.project_combo.setCurrentIndex(0) dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(2) # month
idx_month = dialog.granularity.findData("month")
assert idx_month != -1
dialog.granularity.setCurrentIndex(idx_month)
dialog._run_report() dialog._run_report()
# Should aggregate to single month # Should aggregate to single month
assert dialog.table.rowCount() == 1 assert dialog.table.rowCount() == 1
hours_text = dialog.table.item(0, 3).text() hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text assert "2.5" in hours_text or "2.50" in hours_text
@ -1942,13 +1937,10 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(1) dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
idx_week = dialog.granularity.findData("week")
assert idx_week != -1
dialog.granularity.setCurrentIndex(idx_week)
dialog._run_report() dialog._run_report()
@ -1984,10 +1976,7 @@ def test_time_report_dialog_pdf_export_with_multiple_periods(
dialog.project_combo.setCurrentIndex(0) dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(0) # day
idx_day = dialog.granularity.findData("day")
assert idx_day != -1
dialog.granularity.setCurrentIndex(idx_day)
dialog._run_report() dialog._run_report()
@ -2165,10 +2154,10 @@ def test_full_workflow_add_project_activity_log_report(
# Verify report # Verify report
assert report_dialog.table.rowCount() == 1 assert report_dialog.table.rowCount() == 1
assert "Test Activity" in report_dialog.table.item(0, 2).text() assert "Test Activity" in report_dialog.table.item(0, 1).text()
assert ( assert (
"2.5" in report_dialog.table.item(0, 4).text() "2.5" in report_dialog.table.item(0, 3).text()
or "2.50" in report_dialog.table.item(0, 4).text() or "2.50" in report_dialog.table.item(0, 3).text()
) )
# 5. Export CSV # 5. Export CSV
@ -2944,69 +2933,3 @@ def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db):
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities") dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
qtbot.addWidget(dialog2) qtbot.addWidget(dialog2)
assert dialog2.tabs.currentIndex() == 1 assert dialog2.tabs.currentIndex() == 1
def test_time_report_no_grouping_returns_each_entry_and_note(fresh_db):
"""Granularity 'none' returns one row per entry and includes notes."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
date = _today()
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
report = fresh_db.time_report(proj_id, date, date, "none")
# Two separate rows, not aggregated.
assert len(report) == 2
# Each row is (period, activity_name, note, total_minutes)
periods = {r[0] for r in report}
activities = {r[1] for r in report}
notes = {r[2] for r in report}
minutes = sorted(r[3] for r in report)
assert periods == {date}
assert activities == {"Activity"}
assert notes == {"First", "Second"}
assert minutes == [30, 60]
def test_time_report_dialog_granularity_none_shows_each_entry_and_notes(
qtbot, fresh_db
):
"""'Don't group' granularity shows one row per log entry and includes notes."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
date = _today()
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
# Select the concrete project (index 0 is "All projects")
dialog.project_combo.setCurrentIndex(1)
dialog.from_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
idx_none = dialog.granularity.findData("none")
assert idx_none != -1
dialog.granularity.setCurrentIndex(idx_none)
dialog._run_report()
# Two rows, not aggregated
assert dialog.table.rowCount() == 2
# Notes in column 3
notes = {dialog.table.item(row, 3).text() for row in range(dialog.table.rowCount())}
assert "First" in notes
assert "Second" in notes
# Hours in last column (index 4) when not grouped
hours = [dialog.table.item(row, 4).text() for row in range(dialog.table.rowCount())]
assert any("1.00" in h or "1.0" in h for h in hours)
assert any("0.50" in h or "0.5" in h for h in hours)

View file

@ -1,8 +1,8 @@
import pytest import pytest
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.toolbar import ToolBar
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.toolbar import ToolBar
@pytest.fixture @pytest.fixture

View file

@ -1,10 +1,9 @@
import subprocess
from unittest.mock import Mock, patch
import pytest import pytest
from unittest.mock import Mock, patch
import subprocess
from bouquin.version_check import VersionChecker from bouquin.version_check import VersionChecker
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QMessageBox, QWidget from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QPixmap
def test_version_checker_init(app): def test_version_checker_init(app):