Compare commits

..

6 commits

Author SHA1 Message Date
7a75d33bb0
0.7.0
All checks were successful
CI / test (push) Successful in 8m43s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 21s
2025-12-11 16:17:22 +11:00
57614cefa1
Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
All checks were successful
CI / test (push) Successful in 8m58s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 21s
2025-12-11 15:45:03 +11:00
fb873edcb5
isort followed by black
All checks were successful
CI / test (push) Successful in 9m47s
Lint / test (push) Successful in 40s
Trivy / test (push) Successful in 22s
2025-12-11 14:03:08 +11:00
0862ce7fd6
Say just 'once' (not 'once (today)') in reminders, now that we can set the specific date
All checks were successful
CI / test (push) Successful in 9m23s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 20s
2025-12-10 18:27:15 +11:00
61b3e5b45a
Code comments 2025-12-09 12:48:59 +11:00
81878c63d9
Invoicing
All checks were successful
CI / test (push) Successful in 7m5s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 25s
2025-12-08 20:34:11 +11:00
64 changed files with 4129 additions and 511 deletions

3
.gitignore vendored
View file

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

View file

@ -1,3 +1,9 @@
# 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.

View file

@ -79,8 +79,6 @@ 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).
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).
### From PyPi/pip

View file

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

View file

@ -1,15 +1,14 @@
from __future__ import annotations
from PySide6.QtCore import QSize, QRect, Qt
from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QPlainTextEdit,
QDialogButtonBox,
QComboBox,
QDialog,
QDialogButtonBox,
QLabel,
QPlainTextEdit,
QVBoxLayout,
QWidget,
)

View file

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

View file

@ -5,16 +5,15 @@ import datetime as _dt
import hashlib
import html
import json
import markdown
import mimetypes
import re
from dataclasses import dataclass
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
from sqlcipher3 import Binary
from typing import List, Sequence, Tuple, Dict
from typing import Dict, List, Sequence, Tuple
import markdown
from sqlcipher3 import Binary
from sqlcipher3 import dbapi2 as sqlite
from . import strings
@ -41,6 +40,26 @@ DocumentRow = Tuple[
int, # size_bytes
str, # uploaded_at (ISO)
]
ProjectBillingRow = Tuple[
int, # project_id
int, # hourly_rate_cents
str, # currency
str | None, # tax_label
float | None, # tax_rate_percent
str | None, # client_name
str | None, # client_company
str | None, # client_address
str | None, # client_email
]
CompanyProfileRow = Tuple[
str | None, # name
str | None, # address
str | None, # phone
str | None, # email
str | None, # tax_id
str | None, # payment_details
bytes | None, # logo
]
_TAG_COLORS = [
"#FFB3BA", # soft red
@ -77,11 +96,31 @@ class DBConfig:
time_log: bool = True
reminders: bool = True
documents: bool = True
invoicing: bool = False
locale: str = "en"
font_size: int = 11
class DBManager:
# Allow list of invoice columns allowed for dynamic field helpers
_INVOICE_COLUMN_ALLOWLIST = frozenset(
{
"invoice_number",
"issue_date",
"due_date",
"currency",
"tax_label",
"tax_rate_percent",
"subtotal_cents",
"tax_cents",
"total_cents",
"detail_mode",
"paid_at",
"payment_note",
"document_id",
}
)
def __init__(self, cfg: DBConfig):
self.cfg = cfg
self.conn: sqlite.Connection | None = None
@ -252,6 +291,76 @@ class DBManager:
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
ON document_tags(tag_id);
CREATE TABLE IF NOT EXISTS project_billing (
project_id INTEGER PRIMARY KEY
REFERENCES projects(id) ON DELETE CASCADE,
hourly_rate_cents INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'AUD',
tax_label TEXT,
tax_rate_percent REAL,
client_name TEXT, -- contact person
client_company TEXT, -- business name
client_address TEXT,
client_email TEXT
);
CREATE TABLE IF NOT EXISTS company_profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
address TEXT,
phone TEXT,
email TEXT,
tax_id TEXT,
payment_details TEXT,
logo BLOB
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL
REFERENCES projects(id) ON DELETE RESTRICT,
invoice_number TEXT NOT NULL,
issue_date TEXT NOT NULL, -- yyyy-MM-dd
due_date TEXT,
currency TEXT NOT NULL,
tax_label TEXT,
tax_rate_percent REAL,
subtotal_cents INTEGER NOT NULL,
tax_cents INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
detail_mode TEXT NOT NULL, -- 'detailed' | 'summary'
paid_at TEXT,
payment_note TEXT,
document_id INTEGER,
FOREIGN KEY(document_id) REFERENCES project_documents(id)
ON DELETE SET NULL,
UNIQUE(project_id, invoice_number)
);
CREATE INDEX IF NOT EXISTS ix_invoices_project
ON invoices(project_id);
CREATE TABLE IF NOT EXISTS invoice_line_items (
id INTEGER PRIMARY KEY,
invoice_id INTEGER NOT NULL
REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
hours REAL NOT NULL,
rate_cents INTEGER NOT NULL,
amount_cents INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice
ON invoice_line_items(invoice_id);
CREATE TABLE IF NOT EXISTS invoice_time_log (
invoice_id INTEGER NOT NULL
REFERENCES invoices(id) ON DELETE CASCADE,
time_log_id INTEGER NOT NULL
REFERENCES time_log(id) ON DELETE RESTRICT,
PRIMARY KEY (invoice_id, time_log_id)
);
"""
)
self.conn.commit()
@ -942,6 +1051,14 @@ class DBManager:
).fetchall()
return [(r["id"], r["name"]) for r in rows]
def list_projects_by_id(self, project_id: int) -> str:
cur = self.conn.cursor()
row = cur.execute(
"SELECT name FROM projects WHERE id = ?;",
(project_id,),
).fetchone()
return row["name"] if row else ""
def add_project(self, name: str) -> int:
name = name.strip()
if not name:
@ -1183,7 +1300,7 @@ class DBManager:
cur = self.conn.cursor()
if granularity == "none":
# No grouping one row per time_log record
# No grouping - one row per time_log record
rows = cur.execute(
"""
SELECT
@ -1718,3 +1835,431 @@ class DBManager:
(tag_name,),
).fetchall()
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows]
# ------------------------- Billing settings ------------------------#
def get_project_billing(self, project_id: int) -> ProjectBillingRow | None:
cur = self.conn.cursor()
row = cur.execute(
"""
SELECT
project_id,
hourly_rate_cents,
currency,
tax_label,
tax_rate_percent,
client_name,
client_company,
client_address,
client_email
FROM project_billing
WHERE project_id = ?
""",
(project_id,),
).fetchone()
if not row:
return None
return (
row["project_id"],
row["hourly_rate_cents"],
row["currency"],
row["tax_label"],
row["tax_rate_percent"],
row["client_name"],
row["client_company"],
row["client_address"],
row["client_email"],
)
def upsert_project_billing(
self,
project_id: int,
hourly_rate_cents: int,
currency: str,
tax_label: str | None,
tax_rate_percent: float | None,
client_name: str | None,
client_company: str | None,
client_address: str | None,
client_email: str | None,
) -> None:
with self.conn:
self.conn.execute(
"""
INSERT INTO project_billing (
project_id,
hourly_rate_cents,
currency,
tax_label,
tax_rate_percent,
client_name,
client_company,
client_address,
client_email
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
hourly_rate_cents = excluded.hourly_rate_cents,
currency = excluded.currency,
tax_label = excluded.tax_label,
tax_rate_percent = excluded.tax_rate_percent,
client_name = excluded.client_name,
client_company = excluded.client_company,
client_address = excluded.client_address,
client_email = excluded.client_email;
""",
(
project_id,
hourly_rate_cents,
currency,
tax_label,
tax_rate_percent,
client_name,
client_company,
client_address,
client_email,
),
)
def list_client_companies(self) -> list[str]:
"""Return distinct client display names from project_billing."""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT DISTINCT client_company
FROM project_billing
WHERE client_company IS NOT NULL
AND TRIM(client_company) <> ''
ORDER BY LOWER(client_company);
"""
).fetchall()
return [r["client_company"] for r in rows]
def get_client_by_company(
self, client_company: str
) -> tuple[str | None, str | None, str | None, str | None] | None:
"""
Return (contact_name, client_display_name, address, email)
for a given client display name, based on the most recent project using it.
"""
cur = self.conn.cursor()
row = cur.execute(
"""
SELECT client_name, client_company, client_address, client_email
FROM project_billing
WHERE client_company = ?
AND client_company IS NOT NULL
AND TRIM(client_company) <> ''
ORDER BY project_id DESC
LIMIT 1
""",
(client_company,),
).fetchone()
if not row:
return None
return (
row["client_name"],
row["client_company"],
row["client_address"],
row["client_email"],
)
# ------------------------- Company profile ------------------------#
def get_company_profile(self) -> CompanyProfileRow | None:
cur = self.conn.cursor()
row = cur.execute(
"""
SELECT name, address, phone, email, tax_id, payment_details, logo
FROM company_profile
WHERE id = 1
"""
).fetchone()
if not row:
return None
return (
row["name"],
row["address"],
row["phone"],
row["email"],
row["tax_id"],
row["payment_details"],
row["logo"],
)
def save_company_profile(
self,
name: str | None,
address: str | None,
phone: str | None,
email: str | None,
tax_id: str | None,
payment_details: str | None,
logo: bytes | None,
) -> None:
with self.conn:
self.conn.execute(
"""
INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
address = excluded.address,
phone = excluded.phone,
email = excluded.email,
tax_id = excluded.tax_id,
payment_details = excluded.payment_details,
logo = excluded.logo;
""",
(
name,
address,
phone,
email,
tax_id,
payment_details,
Binary(logo) if logo else None,
),
)
# ------------------------- Invoices -------------------------------#
def create_invoice(
self,
project_id: int,
invoice_number: str,
issue_date: str,
due_date: str | None,
currency: str,
tax_label: str | None,
tax_rate_percent: float | None,
detail_mode: str, # 'detailed' or 'summary'
line_items: list[tuple[str, float, int]], # (description, hours, rate_cents)
time_log_ids: list[int],
) -> int:
"""
Create invoice + line items + link time logs.
Returns invoice ID.
"""
if line_items:
first_rate_cents = line_items[0][2]
else:
first_rate_cents = 0
total_hours = sum(hours for _desc, hours, _rate in line_items)
subtotal_cents = int(round(total_hours * first_rate_cents))
tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0))
total_cents = subtotal_cents + tax_cents
with self.conn:
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO invoices (
project_id,
invoice_number,
issue_date,
due_date,
currency,
tax_label,
tax_rate_percent,
subtotal_cents,
tax_cents,
total_cents,
detail_mode
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project_id,
invoice_number,
issue_date,
due_date,
currency,
tax_label,
tax_rate_percent,
subtotal_cents,
tax_cents,
total_cents,
detail_mode,
),
)
invoice_id = cur.lastrowid
# Line items
for desc, hours, rate_cents in line_items:
amount_cents = int(round(hours * rate_cents))
cur.execute(
"""
INSERT INTO invoice_line_items (
invoice_id, description, hours, rate_cents, amount_cents
)
VALUES (?, ?, ?, ?, ?)
""",
(invoice_id, desc, hours, rate_cents, amount_cents),
)
# Link time logs
for tl_id in time_log_ids:
cur.execute(
"INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)",
(invoice_id, tl_id),
)
return invoice_id
def get_invoice_count_by_project_id_and_year(
self, project_id: int, year: str
) -> None:
with self.conn:
row = self.conn.execute(
"SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?",
(project_id, year),
).fetchone()
return row["c"]
def get_all_invoices(self, project_id: int | None = None) -> None:
with self.conn:
if project_id is None:
rows = self.conn.execute(
"""
SELECT
i.id,
i.project_id,
p.name AS project_name,
i.invoice_number,
i.issue_date,
i.due_date,
i.currency,
i.tax_label,
i.tax_rate_percent,
i.subtotal_cents,
i.tax_cents,
i.total_cents,
i.paid_at,
i.payment_note
FROM invoices AS i
LEFT JOIN projects AS p ON p.id = i.project_id
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
"""
).fetchall()
else:
rows = self.conn.execute(
"""
SELECT
i.id,
i.project_id,
p.name AS project_name,
i.invoice_number,
i.issue_date,
i.due_date,
i.currency,
i.tax_label,
i.tax_rate_percent,
i.subtotal_cents,
i.tax_cents,
i.total_cents,
i.paid_at,
i.payment_note
FROM invoices AS i
LEFT JOIN projects AS p ON p.id = i.project_id
WHERE i.project_id = ?
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
""",
(project_id,),
).fetchall()
return rows
def _validate_invoice_field(self, field: str) -> str:
if field not in self._INVOICE_COLUMN_ALLOWLIST:
raise ValueError(f"Invalid invoice field name: {field!r}")
return field
def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None:
field = self._validate_invoice_field(field)
with self.conn:
row = self.conn.execute(
f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608
(invoice_id,),
).fetchone()
return row
def set_invoice_field_by_id(
self, invoice_id: int, field: str, value: str | None = None
) -> None:
field = self._validate_invoice_field(field)
with self.conn:
self.conn.execute(
f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608
(
value,
invoice_id,
),
)
def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None:
with self.conn:
self.conn.execute(
"UPDATE invoices SET invoice_number = ? WHERE id = ?",
(invoice_number, invoice_id),
)
def set_invoice_document(self, invoice_id: int, document_id: int) -> None:
with self.conn:
self.conn.execute(
"UPDATE invoices SET document_id = ? WHERE id = ?",
(document_id, invoice_id),
)
def time_logs_for_range(
self,
project_id: int,
start_date_iso: str,
end_date_iso: str,
) -> list[TimeLogRow]:
"""
Return raw time log rows for a project/date range.
Shape matches time_log_for_date: TimeLogRow.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT
t.id,
t.page_date,
t.project_id,
p.name AS project_name,
t.activity_id,
a.name AS activity_name,
t.minutes,
t.note,
t.created_at AS created_at
FROM time_log t
JOIN projects p ON p.id = t.project_id
JOIN activities a ON a.id = t.activity_id
WHERE t.project_id = ?
AND t.page_date BETWEEN ? AND ?
ORDER BY t.page_date, LOWER(a.name), t.id;
""",
(project_id, start_date_iso, end_date_iso),
).fetchall()
result: list[TimeLogRow] = []
for r in rows:
result.append(
(
r["id"],
r["page_date"],
r["project_id"],
r["project_name"],
r["activity_id"],
r["activity_name"],
r["minutes"],
r["note"],
r["created_at"],
)
)
return result

View file

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

View file

@ -5,32 +5,32 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QComboBox,
QLineEdit,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QPushButton,
QComboBox,
QDialog,
QFileDialog,
QMessageBox,
QWidget,
QFormLayout,
QFrame,
QToolButton,
QHBoxLayout,
QHeaderView,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QStyle,
QTableWidget,
QTableWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager, DocumentRow
from .settings import load_db_config
from .time_log import TimeCodeManagerDialog
from . import strings
class TodaysDocumentsWidget(QFrame):
@ -112,7 +112,7 @@ class TodaysDocumentsWidget(QFrame):
if project_name:
extra_parts.append(project_name)
if extra_parts:
label = f"{file_name} " + " · ".join(extra_parts)
label = f"{file_name} - " + " · ".join(extra_parts)
item = QListWidgetItem(label)
item.setData(

View file

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

View file

@ -1,22 +1,29 @@
from __future__ import annotations
import difflib, re, html as _html
import difflib
import html as _html
import re
from datetime import datetime
from PySide6.QtCore import Qt, Slot
from PySide6.QtCore import QDate, Qt, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QCalendarWidget,
QDialog,
QVBoxLayout,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QMessageBox,
QTextBrowser,
QPushButton,
QTabWidget,
QAbstractItemView,
QTextBrowser,
QVBoxLayout,
)
from . import strings
from .theme import ThemeManager
def _markdown_to_text(s: str) -> str:
@ -70,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert."""
def __init__(self, db, date_iso: str, parent=None):
def __init__(
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
):
super().__init__(parent)
self.setWindowTitle(f"{strings._('history')}{date_iso}")
self._db = db
self._date = date_iso
self._themes = themes
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
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 = QHBoxLayout()
self.list = QListWidget()
@ -117,6 +137,53 @@ class HistoryDialog(QDialog):
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 ---
def _load_versions(self):
# [{id,version_no,created_at,note,is_current}]

1445
bouquin/invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -52,7 +52,6 @@
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"help": "Help",
"saved": "Saved",
@ -202,6 +201,7 @@
"by_week": "by week",
"date_range": "Date range",
"custom_range": "Custom",
"last_week": "Last week",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
@ -234,6 +234,8 @@
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
@ -249,10 +251,10 @@
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"time_log_date_label": "Time log date: {date}",
"time_log_change_date": "Change date",
"time_log_select_date_title": "Select time log date",
"time_log_for": "Time log for {date}",
"date_label": "Date: {date}",
"change_date": "Change date",
"select_date_title": "Select date",
"for": "For {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
@ -304,7 +306,7 @@
"reminder": "Reminder",
"reminders": "Reminders",
"time": "Time",
"once_today": "Once (today)",
"once": "Once",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
@ -359,5 +361,54 @@
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"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",
"edit_reminder": "Modifier le rappel",
"time": "Heure",
"once_today": "Une fois (aujourd'hui)",
"once": "Une fois (aujourd'hui)",
"every_day": "Tous les jours",
"every_weekday": "Tous les jours de semaine (lun-ven)",
"every_week": "Toutes les semaines",

View file

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

View file

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

View file

@ -2,21 +2,21 @@ from __future__ import annotations
import datetime
import os
import sys
import re
import sys
from pathlib import Path
from PySide6.QtCore import (
QDate,
QTimer,
Qt,
QSettings,
Slot,
QUrl,
QEvent,
QSignalBlocker,
QDateTime,
QEvent,
QSettings,
QSignalBlocker,
Qt,
QTime,
QTimer,
QUrl,
Slot,
)
from PySide6.QtGui import (
QAction,
@ -31,23 +31,24 @@ from PySide6.QtGui import (
QTextListFormat,
)
from PySide6.QtWidgets import (
QApplication,
QCalendarWidget,
QDialog,
QFileDialog,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QTableView,
QTabWidget,
QVBoxLayout,
QWidget,
QLabel,
QPushButton,
QApplication,
)
from . import strings
from .bug_report_dialog import BugReportDialog
from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget
@ -60,10 +61,9 @@ from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
from . import strings
from .tags_widget import PageTagsWidget
from .theme import ThemeManager
from .time_log import TimeLogWidget
@ -117,6 +117,9 @@ class MainWindow(QMainWindow):
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
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)
# Lock the calendar to the left panel at the top to stop it stretching
@ -493,7 +496,7 @@ class MainWindow(QMainWindow):
idx = self._tab_index_for_date(date)
if idx != -1:
self.tab_widget.setCurrentIndex(idx)
# keep calendar selection in sync (dont trigger load)
# keep calendar selection in sync (don't trigger load)
from PySide6.QtCore import QSignalBlocker
with QSignalBlocker(self.calendar):
@ -516,7 +519,7 @@ class MainWindow(QMainWindow):
editor = MarkdownEditor(self.themes)
# Apply users preferred font size
# Apply user's preferred font size
self._apply_font_size(editor)
# Set up the editor's event connections
@ -1351,7 +1354,7 @@ class MainWindow(QMainWindow):
else:
date_iso = self._current_date_iso()
dlg = HistoryDialog(self.db, date_iso, self)
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
if dlg.exec() == QDialog.Accepted:
# refresh editor + calendar (head pointer may have changed)
self._load_selected_date(date_iso)
@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow):
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.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.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)

View file

@ -5,28 +5,28 @@ import re
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtCore import QRect, Qt, QTimer, QUrl
from PySide6.QtGui import (
QDesktopServices,
QFont,
QFontDatabase,
QFontMetrics,
QImage,
QMouseEvent,
QTextBlock,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
QTextDocument,
QTextFormat,
QTextBlockFormat,
QTextImageFormat,
QDesktopServices,
)
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QDialog, QTextEdit
from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter
from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings
from .code_block_editor_dialog import CodeBlockEditorDialog
from .markdown_highlighter import MarkdownHighlighter
from .theme import ThemeManager
class MarkdownEditor(QTextEdit):
@ -382,7 +382,7 @@ class MarkdownEditor(QTextEdit):
cursor.removeSelectedText()
cursor.insertText("\n" + new_text + "\n")
else:
# Empty block keep one blank line inside the fences
# Empty block - keep one blank line inside the fences
cursor.removeSelectedText()
cursor.insertText("\n\n")
cursor.endEditBlock()
@ -789,7 +789,7 @@ class MarkdownEditor(QTextEdit):
"""
# 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).
# So dont rewrite the selection in that case.
# So don't rewrite the selection in that case.
if getattr(self, "_mouse_drag_selecting", False):
return
@ -863,7 +863,7 @@ class MarkdownEditor(QTextEdit):
):
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
# Bullet list Unicode bullet
# Bullet list - Unicode bullet
if line.startswith(f"{self._BULLET_DISPLAY} "):
return ("bullet", f"{self._BULLET_DISPLAY} ")
@ -1055,7 +1055,7 @@ class MarkdownEditor(QTextEdit):
# of list prefixes (checkboxes / bullets / numbers).
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
# 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:
pass
else:
@ -1367,7 +1367,7 @@ class MarkdownEditor(QTextEdit):
cursor = self.cursorForPosition(event.pos())
block = cursor.block()
# If were on or inside a code block, open the editor instead
# If we're on or inside a code block, open the editor instead
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
# Only swallow the double-click if we actually opened a dialog.
if not self._edit_code_block(block):

View file

@ -14,7 +14,7 @@ from PySide6.QtGui import (
QTextDocument,
)
from .theme import ThemeManager, Theme
from .theme import Theme, ThemeManager
class MarkdownHighlighter(QSyntaxHighlighter):

View file

@ -6,10 +6,10 @@ from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QFrame,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
@ -133,7 +133,7 @@ class PomodoroManager:
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
# Fallback - just attach it as a child widget
self._active_timer.setParent(time_log_widget)
self._active_timer.show()

View file

@ -4,30 +4,30 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QAbstractItemView,
QComboBox,
QTimeEdit,
QPushButton,
QDateEdit,
QDialog,
QFormLayout,
QFrame,
QWidget,
QToolButton,
QHBoxLayout,
QHeaderView,
QLineEdit,
QListWidget,
QListWidgetItem,
QStyle,
QSizePolicy,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QSpinBox,
QDateEdit,
QTimeEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import strings
@ -107,7 +107,7 @@ class ReminderDialog(QDialog):
# Recurrence type
self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
@ -484,7 +484,7 @@ class UpcomingRemindersWidget(QFrame):
offset = (target_dow - first.dayOfWeek() + 7) % 7
candidate = first.addDays(offset + anchor_n * 7)
# If that nth weekday doesnt exist this month (e.g. 5th Monday), skip
# If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
if candidate.month() != date.month():
return False
@ -566,8 +566,8 @@ class UpcomingRemindersWidget(QFrame):
if not selected_items:
return
from PySide6.QtWidgets import QMenu
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu
menu = QMenu(self)

View file

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

View file

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

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
@ -45,6 +46,7 @@ def load_db_config() -> DBConfig:
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
documents = s.value("ui/documents", True, type=bool)
invoicing = s.value("ui/invoicing", True, type=bool)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig(
@ -57,6 +59,7 @@ def load_db_config() -> DBConfig:
time_log=time_log,
reminders=reminders,
documents=documents,
invoicing=invoicing,
locale=locale,
font_size=font_size,
)
@ -73,5 +76,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/documents", str(cfg.documents))
s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -2,34 +2,36 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout,
QVBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QDialogButtonBox,
QRadioButton,
QSizePolicy,
QSpinBox,
QMessageBox,
QWidget,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from .db import DBConfig, DBManager
from .settings import load_db_config, save_db_config
from .theme import Theme
from .key_prompt import KeyPrompt
from . import strings
from .db import DBConfig, DBManager
from .key_prompt import KeyPrompt
from .settings import load_db_config, save_db_config
from .theme import Theme
class SettingsDialog(QDialog):
@ -176,6 +178,17 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor)
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)
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor)
@ -187,6 +200,68 @@ class SettingsDialog(QDialog):
features_layout.addWidget(self.documents)
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()
return page
@ -314,14 +389,60 @@ class SettingsDialog(QDialog):
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
documents=self.documents.isChecked(),
invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False
),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
)
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.accept()
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):
p1 = KeyPrompt(
self,

View file

@ -3,26 +3,25 @@ from __future__ import annotations
import datetime as _dt
from typing import Dict
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QVBoxLayout,
QFormLayout,
QLabel,
QGroupBox,
QHBoxLayout,
QComboBox,
QLabel,
QScrollArea,
QWidget,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .settings import load_db_config
# ---------- Activity heatmap ----------
@ -216,7 +215,7 @@ class DateHeatmap(QWidget):
col = int((x - self._margin_left) // cell_span) # week index
row = int((y - self._margin_top) // cell_span) # dow (0..6)
# Only 7 rows (MonSun)
# Only 7 rows (Mon-Sun)
if not (0 <= row < 7):
return

View file

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

View file

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

View file

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

View file

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

View file

@ -2,49 +2,49 @@ from __future__ import annotations
import csv
import html
from collections import defaultdict
from datetime import datetime
from sqlcipher3.dbapi2 import IntegrityError
from typing import Optional
from PySide6.QtCore import Qt, QDate, QUrl
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
from PySide6.QtCore import QDate, Qt, QUrl, Signal
from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import (
QAbstractItemView,
QCalendarWidget,
QComboBox,
QCompleter,
QDateEdit,
QDialog,
QDialogButtonBox,
QFrame,
QVBoxLayout,
QHBoxLayout,
QWidget,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QLabel,
QComboBox,
QLineEdit,
QDoubleSpinBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QFrame,
QHBoxLayout,
QHeaderView,
QTabWidget,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QDateEdit,
QMessageBox,
QCompleter,
QToolButton,
QPushButton,
QSizePolicy,
QStyle,
QInputDialog,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from sqlcipher3.dbapi2 import IntegrityError
from .db import DBManager
from .theme import ThemeManager
from . import strings
from .db import DBManager
from .settings import load_db_config
from .theme import ThemeManager
class TimeLogWidget(QFrame):
@ -53,6 +53,8 @@ class TimeLogWidget(QFrame):
Shown in the left sidebar above the Tags widget.
"""
remindersChanged = Signal()
def __init__(
self,
db: DBManager,
@ -61,6 +63,7 @@ class TimeLogWidget(QFrame):
):
super().__init__(parent)
self._db = db
self.cfg = load_db_config()
self._themes = themes
self._current_date: Optional[str] = None
@ -82,6 +85,15 @@ class TimeLogWidget(QFrame):
self.log_btn.setAutoRaise(True)
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.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
@ -95,6 +107,7 @@ class TimeLogWidget(QFrame):
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.log_btn)
header.addWidget(self.report_btn)
header.addWidget(self.open_btn)
# Body: simple summary label for the day
@ -149,6 +162,14 @@ class TimeLogWidget(QFrame):
# ----- 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:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
@ -247,7 +268,8 @@ class TimeLogDialog(QDialog):
self._themes = themes
self._date_iso = date_iso
self._current_entry_id: Optional[int] = None
# Guard flag used when repopulating the table so we dont treat
self.cfg = load_db_config()
# Guard flag used when repopulating the table so we don't treat
# programmatic item changes as user edits.
self._reloading_entries: bool = False
@ -255,7 +277,7 @@ class TimeLogDialog(QDialog):
self.close_after_add = close_after_add
self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.setWindowTitle(strings._("for").format(date=date_iso))
self.resize(900, 600)
root = QVBoxLayout(self)
@ -263,12 +285,12 @@ class TimeLogDialog(QDialog):
# --- Top: date label + change-date button
date_row = QHBoxLayout()
self.date_label = QLabel(strings._("time_log_date_label").format(date=date_iso))
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._("time_log_change_date"))
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)
@ -320,13 +342,9 @@ class TimeLogDialog(QDialog):
self.delete_btn.clicked.connect(self._on_delete_entry)
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.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn)
btn_row.addWidget(self.report_btn)
root.addLayout(btn_row)
# --- Table of entries for this date
@ -355,12 +373,19 @@ class TimeLogDialog(QDialog):
self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1)
# --- Total time and Close button
# --- Total time, Reporting and Close button
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_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
@ -452,7 +477,7 @@ class TimeLogDialog(QDialog):
current_qdate = QDate.currentDate()
dlg = QDialog(self)
dlg.setWindowTitle(strings._("time_log_select_date_title"))
dlg.setWindowTitle(strings._("select_date_title"))
layout = QVBoxLayout(dlg)
@ -483,8 +508,8 @@ class TimeLogDialog(QDialog):
self._date_iso = new_iso
# Update window title and header label
self.setWindowTitle(strings._("time_log_for").format(date=new_iso))
self.date_label.setText(strings._("time_log_date_label").format(date=new_iso))
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._reload_entries()
@ -594,7 +619,7 @@ class TimeLogDialog(QDialog):
hours_item = self.table.item(row, 3)
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
# Recover the entry id from the hidden UserRole on the project cell
@ -803,7 +828,7 @@ class TimeCodeManagerDialog(QDialog):
try:
self._db.add_project(name)
except ValueError:
# Empty / invalid name nothing to do, but be defensive
# Empty / invalid name - nothing to do, but be defensive
QMessageBox.warning(
self,
strings._("invalid_project_title"),
@ -981,9 +1006,12 @@ class TimeReportDialog(QDialog):
Shows decimal hours per time period.
"""
remindersChanged = Signal()
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
self.cfg = load_db_config()
# state for last run
self._last_rows: list[tuple[str, str, str, str, int]] = []
@ -992,6 +1020,7 @@ class TimeReportDialog(QDialog):
self._last_start: str = ""
self._last_end: str = ""
self._last_gran_label: str = ""
self._last_time_logs: list = []
self.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400)
@ -999,9 +1028,20 @@ class TimeReportDialog(QDialog):
root = QVBoxLayout(self)
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
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():
self.project_combo.addItem(name, proj_id)
form.addRow(strings._("project"), self.project_combo)
@ -1013,6 +1053,7 @@ class TimeReportDialog(QDialog):
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")
@ -1061,6 +1102,10 @@ class TimeReportDialog(QDialog):
run_row.addWidget(run_btn)
run_row.addWidget(export_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)
# Table
@ -1146,6 +1191,14 @@ class TimeReportDialog(QDialog):
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
@ -1154,7 +1207,7 @@ class TimeReportDialog(QDialog):
start = QDate(today.year(), 1, 1)
end = today
else: # "custom" leave fields as user-set
else: # "custom" - leave fields as user-set
return
# Update date edits without triggering anything else
@ -1187,11 +1240,13 @@ class TimeReportDialog(QDialog):
if proj_data is None:
# All projects
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
@ -1228,7 +1283,7 @@ class TimeReportDialog(QDialog):
# no note column
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
# Summary label include per-project totals when in "all projects" mode
# Summary label - include per-project totals when in "all projects" mode
total_hours = self._last_total_minutes / 60.0
if self._last_all_projects:
per_project_bits = [
@ -1525,3 +1580,55 @@ class TimeReportDialog(QDialog):
strings._("export_pdf_error_title"),
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 PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
from PySide6.QtWidgets import QToolBar
from . import strings

View file

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

196
poetry.lock generated
View file

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

View file

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

View file

@ -3,7 +3,7 @@
set -eo pipefail
# Clean caches etc
/home/user/venv-guardutils/bin/filedust -y .
filedust -y .
# Publish to Pypi
poetry build

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,12 @@
import pytest
import json, csv
import csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
import json
from datetime import date, timedelta
import pytest
from bouquin.db import DBManager
from sqlcipher3 import dbapi2 as sqlite
def _today():
return dt.date.today().isoformat()

View file

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

View file

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

View file

@ -1,10 +1,9 @@
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.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

View file

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

1346
tests/test_invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,22 +1,19 @@
import pytest
import importlib.metadata
from datetime import date, timedelta
from pathlib import Path
import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt
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.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.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):

View file

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

View file

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

View file

@ -1,8 +1,9 @@
from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
class DummyTimeLogWidget(QWidget):

View file

@ -1,17 +1,16 @@
import pytest
from unittest.mock import patch, MagicMock
from bouquin.reminders import (
Reminder,
ReminderType,
ReminderDialog,
UpcomingRemindersWidget,
ManageRemindersDialog,
)
from PySide6.QtCore import QDateTime, QDate, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
from datetime import date, timedelta
from unittest.mock import MagicMock, patch
import pytest
from bouquin.reminders import (
ManageRemindersDialog,
Reminder,
ReminderDialog,
ReminderType,
UpcomingRemindersWidget,
)
from PySide6.QtCore import QDate, QDateTime, QTime
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
@pytest.fixture
@ -851,9 +850,9 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
def test_upcoming_reminders_context_menu_shows(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
from PySide6 import QtWidgets, QtGui
from PySide6.QtCore import QPoint
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
from PySide6 import QtGui, QtWidgets
from PySide6.QtCore import QPoint
# Add a future reminder for today
r = Reminder(
@ -909,9 +908,9 @@ def test_upcoming_reminders_context_menu_shows(
def test_upcoming_reminders_delete_selected_dedupes(
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
):
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QItemSelectionModel
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
from PySide6.QtCore import QItemSelectionModel
from PySide6.QtWidgets import QMessageBox
r = Reminder(
id=None,

View file

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

View file

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

View file

@ -1,13 +1,11 @@
import datetime as _dt
from datetime import datetime, timedelta, date
from datetime import date, datetime, timedelta
from bouquin import strings
from PySide6.QtCore import Qt, QPoint, QDate
from PySide6.QtWidgets import QLabel, QWidget
from PySide6.QtTest import QTest
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QLabel, QWidget
class FakeStatsDB:
@ -632,5 +630,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
# Force a repaint to execute paintEvent
heatmap.repaint()
# The month continuation logic (line 175) should prevent duplicate labels
# The month continuation logic should prevent duplicate labels
# We can't easily test the visual output, but we ensure no crash

View file

@ -1,12 +1,11 @@
import types
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
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
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):

View file

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

View file

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

View file

@ -1,24 +1,18 @@
import pytest
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 unittest.mock import MagicMock, patch
from bouquin.theme import ThemeManager, ThemeConfig, Theme
import bouquin.strings as strings
import pytest
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.time_log import (
TimeLogWidget,
TimeLogDialog,
TimeCodeManagerDialog,
TimeLogDialog,
TimeLogWidget,
TimeReportDialog,
)
import bouquin.strings as strings
from unittest.mock import patch, MagicMock
from PySide6.QtCore import QDate, Qt
from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
from sqlcipher3.dbapi2 import IntegrityError
@pytest.fixture

View file

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

View file

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