Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886b809bd3 | |||
| e6010969cb | |||
| 492633df9f | |||
| dcb62d34af | |||
| 13b1ad7373 | |||
| 7abd99fe24 | |||
| 2112de39b8 | |||
| 206670454f | |||
| 3106d408ab | |||
| d809244cf8 | |||
| 28446340f8 | |||
| c1c95ca0ca | |||
| 7a75d33bb0 | |||
| 57614cefa1 | |||
| fb873edcb5 | |||
| 0862ce7fd6 | |||
| 61b3e5b45a | |||
| 81878c63d9 | |||
| e5c7ccb1da | |||
| 9b2260f6a7 | |||
| aeb3d863e2 | |||
| f5c52eaf3b | |||
| 778d988ebd |
67 changed files with 5447 additions and 776 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__
|
||||||
dist
|
dist
|
||||||
.coverage
|
.coverage
|
||||||
*.db
|
*.db
|
||||||
|
*.pdf
|
||||||
|
*.csv
|
||||||
|
*.html
|
||||||
|
|
|
||||||
26
.pre-commit-config.yaml
Normal file
26
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 7.3.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
args: ["--select=F"]
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
|
rev: 25.11.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.4.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.9.2
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
files: ^bouquin/
|
||||||
|
args: ["-s", "B110"]
|
||||||
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -1,3 +1,33 @@
|
||||||
|
# 0.7.3
|
||||||
|
|
||||||
|
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
|
||||||
|
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
|
||||||
|
|
||||||
|
# 0.7.2
|
||||||
|
|
||||||
|
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
|
||||||
|
|
||||||
|
# 0.7.1
|
||||||
|
|
||||||
|
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
|
||||||
|
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
|
||||||
|
* Invoicing should not be enabled by default
|
||||||
|
* Fix Reminders to fire right on the minute after adding them during runtime
|
||||||
|
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
|
||||||
|
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
|
||||||
|
|
||||||
|
# 0.7.0
|
||||||
|
|
||||||
|
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
|
||||||
|
* Add 'Last week' to Time Report dialog range option
|
||||||
|
* Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
|
||||||
|
|
||||||
|
# 0.6.4
|
||||||
|
|
||||||
|
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
|
||||||
|
* Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
|
||||||
|
* Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
|
||||||
|
|
||||||
# 0.6.3
|
# 0.6.3
|
||||||
|
|
||||||
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
|
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||||
|
|
||||||
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
|
|
||||||
|
|
||||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||||
|
|
||||||
### From PyPi/pip
|
### From PyPi/pip
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,17 @@ from __future__ import annotations
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QTextEdit,
|
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
|
QLabel,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
||||||
|
|
||||||
BUG_REPORT_HOST = "https://nr.mig5.net"
|
BUG_REPORT_HOST = "https://nr.mig5.net"
|
||||||
ROUTE = "forms/bouquin/bugs"
|
ROUTE = "forms/bouquin/bugs"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import QSize, QRect, Qt
|
from PySide6.QtCore import QRect, QSize, Qt
|
||||||
from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
|
||||||
QVBoxLayout,
|
|
||||||
QPlainTextEdit,
|
|
||||||
QDialogButtonBox,
|
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
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:
|
class CodeHighlighter:
|
||||||
|
|
|
||||||
815
bouquin/db.py
815
bouquin/db.py
|
|
@ -5,16 +5,15 @@ import datetime as _dt
|
||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import markdown
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
from typing import Dict, List, Sequence, Tuple
|
||||||
from sqlcipher3 import Binary
|
|
||||||
from typing import List, Sequence, Tuple, Dict
|
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from sqlcipher3 import Binary
|
||||||
|
from sqlcipher3 import dbapi2 as sqlite
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
||||||
|
|
@ -41,6 +40,26 @@ DocumentRow = Tuple[
|
||||||
int, # size_bytes
|
int, # size_bytes
|
||||||
str, # uploaded_at (ISO)
|
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 = [
|
_TAG_COLORS = [
|
||||||
"#FFB3BA", # soft red
|
"#FFB3BA", # soft red
|
||||||
|
|
@ -73,15 +92,38 @@ class DBConfig:
|
||||||
idle_minutes: int = 15 # 0 = never lock
|
idle_minutes: int = 15 # 0 = never lock
|
||||||
theme: str = "system"
|
theme: str = "system"
|
||||||
move_todos: bool = False
|
move_todos: bool = False
|
||||||
|
move_todos_include_weekends: bool = False
|
||||||
tags: bool = True
|
tags: bool = True
|
||||||
time_log: bool = True
|
time_log: bool = True
|
||||||
reminders: bool = True
|
reminders: bool = True
|
||||||
|
reminders_webhook_url: str = (None,)
|
||||||
|
reminders_webhook_secret: str = (None,)
|
||||||
documents: bool = True
|
documents: bool = True
|
||||||
|
invoicing: bool = False
|
||||||
locale: str = "en"
|
locale: str = "en"
|
||||||
font_size: int = 11
|
font_size: int = 11
|
||||||
|
|
||||||
|
|
||||||
class DBManager:
|
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):
|
def __init__(self, cfg: DBConfig):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.conn: sqlite.Connection | None = None
|
self.conn: sqlite.Connection | None = None
|
||||||
|
|
@ -252,6 +294,76 @@ class DBManager:
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
|
CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id
|
||||||
ON 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()
|
self.conn.commit()
|
||||||
|
|
@ -862,7 +974,7 @@ class DBManager:
|
||||||
|
|
||||||
# 2 & 3) total revisions + page with most revisions + per-date counts
|
# 2 & 3) total revisions + page with most revisions + per-date counts
|
||||||
total_revisions = 0
|
total_revisions = 0
|
||||||
page_most_revisions = None
|
page_most_revisions: str | None = None
|
||||||
page_most_revisions_count = 0
|
page_most_revisions_count = 0
|
||||||
revisions_by_date: Dict[_dt.date, int] = {}
|
revisions_by_date: Dict[_dt.date, int] = {}
|
||||||
|
|
||||||
|
|
@ -899,7 +1011,6 @@ class DBManager:
|
||||||
words_by_date[d] = wc
|
words_by_date[d] = wc
|
||||||
|
|
||||||
# tags + page with most tags
|
# tags + page with most tags
|
||||||
|
|
||||||
rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
|
rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
|
||||||
unique_tags = int(rows[0]["total_unique"]) if rows else 0
|
unique_tags = int(rows[0]["total_unique"]) if rows else 0
|
||||||
|
|
||||||
|
|
@ -920,6 +1031,119 @@ class DBManager:
|
||||||
page_most_tags = None
|
page_most_tags = None
|
||||||
page_most_tags_count = 0
|
page_most_tags_count = 0
|
||||||
|
|
||||||
|
# 5) Time logging stats (minutes / hours)
|
||||||
|
time_minutes_by_date: Dict[_dt.date, int] = {}
|
||||||
|
total_time_minutes = 0
|
||||||
|
day_most_time: str | None = None
|
||||||
|
day_most_time_minutes = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT page_date, SUM(minutes) AS total_minutes
|
||||||
|
FROM time_log
|
||||||
|
GROUP BY page_date
|
||||||
|
ORDER BY page_date;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
date_iso = r["page_date"]
|
||||||
|
if not date_iso:
|
||||||
|
continue
|
||||||
|
m = int(r["total_minutes"] or 0)
|
||||||
|
total_time_minutes += m
|
||||||
|
if m > day_most_time_minutes:
|
||||||
|
day_most_time_minutes = m
|
||||||
|
day_most_time = date_iso
|
||||||
|
try:
|
||||||
|
d = _dt.date.fromisoformat(date_iso)
|
||||||
|
except Exception: # nosec B112
|
||||||
|
continue
|
||||||
|
time_minutes_by_date[d] = m
|
||||||
|
|
||||||
|
# Project with most logged time
|
||||||
|
project_most_minutes_name: str | None = None
|
||||||
|
project_most_minutes = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.name AS project_name,
|
||||||
|
SUM(t.minutes) AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
GROUP BY t.project_id, p.name
|
||||||
|
ORDER BY total_minutes DESC, LOWER(project_name) ASC
|
||||||
|
LIMIT 1;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
project_most_minutes_name = rows[0]["project_name"]
|
||||||
|
project_most_minutes = int(rows[0]["total_minutes"] or 0)
|
||||||
|
|
||||||
|
# Activity with most logged time
|
||||||
|
activity_most_minutes_name: str | None = None
|
||||||
|
activity_most_minutes = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.name AS activity_name,
|
||||||
|
SUM(t.minutes) AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
GROUP BY t.activity_id, a.name
|
||||||
|
ORDER BY total_minutes DESC, LOWER(activity_name) ASC
|
||||||
|
LIMIT 1;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
activity_most_minutes_name = rows[0]["activity_name"]
|
||||||
|
activity_most_minutes = int(rows[0]["total_minutes"] or 0)
|
||||||
|
|
||||||
|
# 6) Reminder stats
|
||||||
|
reminders_by_date: Dict[_dt.date, int] = {}
|
||||||
|
total_reminders = 0
|
||||||
|
day_most_reminders: str | None = None
|
||||||
|
day_most_reminders_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT substr(created_at, 1, 10) AS date_iso,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM reminders
|
||||||
|
GROUP BY date_iso
|
||||||
|
ORDER BY date_iso;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
date_iso = r["date_iso"]
|
||||||
|
if not date_iso:
|
||||||
|
continue
|
||||||
|
c = int(r["c"] or 0)
|
||||||
|
total_reminders += c
|
||||||
|
if c > day_most_reminders_count:
|
||||||
|
day_most_reminders_count = c
|
||||||
|
day_most_reminders = date_iso
|
||||||
|
try:
|
||||||
|
d = _dt.date.fromisoformat(date_iso)
|
||||||
|
except Exception: # nosec B112
|
||||||
|
continue
|
||||||
|
reminders_by_date[d] = c
|
||||||
|
|
||||||
return (
|
return (
|
||||||
pages_with_content,
|
pages_with_content,
|
||||||
total_revisions,
|
total_revisions,
|
||||||
|
|
@ -931,6 +1155,18 @@ class DBManager:
|
||||||
page_most_tags,
|
page_most_tags,
|
||||||
page_most_tags_count,
|
page_most_tags_count,
|
||||||
revisions_by_date,
|
revisions_by_date,
|
||||||
|
time_minutes_by_date,
|
||||||
|
total_time_minutes,
|
||||||
|
day_most_time,
|
||||||
|
day_most_time_minutes,
|
||||||
|
project_most_minutes_name,
|
||||||
|
project_most_minutes,
|
||||||
|
activity_most_minutes_name,
|
||||||
|
activity_most_minutes,
|
||||||
|
reminders_by_date,
|
||||||
|
total_reminders,
|
||||||
|
day_most_reminders,
|
||||||
|
day_most_reminders_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -------- Time logging: projects & activities ---------------------
|
# -------- Time logging: projects & activities ---------------------
|
||||||
|
|
@ -942,6 +1178,14 @@ class DBManager:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [(r["id"], r["name"]) for r in rows]
|
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:
|
def add_project(self, name: str) -> int:
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -1108,8 +1352,8 @@ class DBManager:
|
||||||
project_id: int,
|
project_id: int,
|
||||||
start_date_iso: str,
|
start_date_iso: str,
|
||||||
end_date_iso: str,
|
end_date_iso: str,
|
||||||
granularity: str = "day", # 'day' | 'week' | 'month'
|
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
|
||||||
) -> list[tuple[str, str, int]]:
|
) -> list[tuple[str, str, str, int]]:
|
||||||
"""
|
"""
|
||||||
Return (time_period, activity_name, total_minutes) tuples between start and end
|
Return (time_period, activity_name, total_minutes) tuples between start and end
|
||||||
for a project, grouped by period and activity.
|
for a project, grouped by period and activity.
|
||||||
|
|
@ -1117,7 +1361,54 @@ class DBManager:
|
||||||
- 'YYYY-MM-DD' for day
|
- 'YYYY-MM-DD' for day
|
||||||
- 'YYYY-WW' for week
|
- 'YYYY-WW' for week
|
||||||
- 'YYYY-MM' for month
|
- 'YYYY-MM' for month
|
||||||
|
For 'activity' granularity, results are grouped by activity only (no time bucket).
|
||||||
|
For 'none' granularity, each individual time log entry becomes a row.
|
||||||
"""
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
if granularity == "none":
|
||||||
|
# No grouping: one row per entry
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.page_date AS period,
|
||||||
|
a.name AS activity_name,
|
||||||
|
t.note AS note,
|
||||||
|
t.minutes AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
AND t.page_date BETWEEN ? AND ?
|
||||||
|
ORDER BY period, LOWER(a.name), t.id;
|
||||||
|
""",
|
||||||
|
(project_id, start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(r["period"], r["activity_name"], r["note"], r["total_minutes"])
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
if granularity == "activity":
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
a.name AS activity_name,
|
||||||
|
SUM(t.minutes) AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
AND t.page_date BETWEEN ? AND ?
|
||||||
|
GROUP BY activity_name
|
||||||
|
ORDER BY LOWER(activity_name);
|
||||||
|
""",
|
||||||
|
(project_id, start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# period column is unused for activity grouping in the UI, but we keep
|
||||||
|
# the tuple shape consistent.
|
||||||
|
return [("", r["activity_name"], "", r["total_minutes"]) for r in rows]
|
||||||
|
|
||||||
if granularity == "day":
|
if granularity == "day":
|
||||||
bucket_expr = "page_date"
|
bucket_expr = "page_date"
|
||||||
elif granularity == "week":
|
elif granularity == "week":
|
||||||
|
|
@ -1126,13 +1417,11 @@ class DBManager:
|
||||||
else: # month
|
else: # month
|
||||||
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
||||||
|
|
||||||
cur = self.conn.cursor()
|
|
||||||
rows = cur.execute(
|
rows = cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
{bucket_expr} AS bucket,
|
{bucket_expr} AS bucket,
|
||||||
a.name AS activity_name,
|
a.name AS activity_name,
|
||||||
t.note AS note,
|
|
||||||
SUM(t.minutes) AS total_minutes
|
SUM(t.minutes) AS total_minutes
|
||||||
FROM time_log t
|
FROM time_log t
|
||||||
JOIN activities a ON a.id = t.activity_id
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
|
@ -1144,21 +1433,81 @@ class DBManager:
|
||||||
(project_id, start_date_iso, end_date_iso),
|
(project_id, start_date_iso, end_date_iso),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [
|
return [(r["bucket"], r["activity_name"], "", r["total_minutes"]) for r in rows]
|
||||||
(r["bucket"], r["activity_name"], r["note"], r["total_minutes"])
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
def time_report_all(
|
def time_report_all(
|
||||||
self,
|
self,
|
||||||
start_date_iso: str,
|
start_date_iso: str,
|
||||||
end_date_iso: str,
|
end_date_iso: str,
|
||||||
granularity: str = "day", # 'day' | 'week' | 'month'
|
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
|
||||||
) -> list[tuple[str, str, str, str, int]]:
|
) -> list[tuple[str, str, str, str, int]]:
|
||||||
"""
|
"""
|
||||||
Return (project_name, time_period, activity_name, note, total_minutes)
|
Return (project_name, time_period, activity_name, note, total_minutes)
|
||||||
across *all* projects between start and end, grouped by project + period + activity.
|
across *all* projects between start and end.
|
||||||
|
- For 'day'/'week'/'month', grouped by project + period + activity.
|
||||||
|
- For 'activity', grouped by project + activity.
|
||||||
|
- For 'none', one row per time_log entry.
|
||||||
"""
|
"""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
if granularity == "none":
|
||||||
|
# No grouping - one row per time_log record
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.name AS project_name,
|
||||||
|
t.page_date AS period,
|
||||||
|
a.name AS activity_name,
|
||||||
|
t.note AS note,
|
||||||
|
t.minutes AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
WHERE t.page_date BETWEEN ? AND ?
|
||||||
|
ORDER BY LOWER(p.name), period, LOWER(activity_name), t.id;
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
r["project_name"],
|
||||||
|
r["period"],
|
||||||
|
r["activity_name"],
|
||||||
|
r["note"],
|
||||||
|
r["total_minutes"],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
if granularity == "activity":
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.name AS project_name,
|
||||||
|
a.name AS activity_name,
|
||||||
|
SUM(t.minutes) AS total_minutes
|
||||||
|
FROM time_log t
|
||||||
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
WHERE t.page_date BETWEEN ? AND ?
|
||||||
|
GROUP BY p.id, activity_name
|
||||||
|
ORDER BY LOWER(p.name), LOWER(activity_name);
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
r["project_name"],
|
||||||
|
"",
|
||||||
|
r["activity_name"],
|
||||||
|
"",
|
||||||
|
r["total_minutes"],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
if granularity == "day":
|
if granularity == "day":
|
||||||
bucket_expr = "page_date"
|
bucket_expr = "page_date"
|
||||||
elif granularity == "week":
|
elif granularity == "week":
|
||||||
|
|
@ -1166,14 +1515,12 @@ class DBManager:
|
||||||
else: # month
|
else: # month
|
||||||
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
||||||
|
|
||||||
cur = self.conn.cursor()
|
|
||||||
rows = cur.execute(
|
rows = cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
p.name AS project_name,
|
p.name AS project_name,
|
||||||
{bucket_expr} AS bucket,
|
{bucket_expr} AS bucket,
|
||||||
a.name AS activity_name,
|
a.name AS activity_name,
|
||||||
t.note AS note,
|
|
||||||
SUM(t.minutes) AS total_minutes
|
SUM(t.minutes) AS total_minutes
|
||||||
FROM time_log t
|
FROM time_log t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
|
@ -1190,7 +1537,7 @@ class DBManager:
|
||||||
r["project_name"],
|
r["project_name"],
|
||||||
r["bucket"],
|
r["bucket"],
|
||||||
r["activity_name"],
|
r["activity_name"],
|
||||||
r["note"],
|
"",
|
||||||
r["total_minutes"],
|
r["total_minutes"],
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|
@ -1667,3 +2014,431 @@ class DBManager:
|
||||||
(tag_name,),
|
(tag_name,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows]
|
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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ and TagBrowserDialog).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QUrl
|
from PySide6.QtCore import QUrl
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,32 @@ from typing import Optional
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
|
||||||
QFormLayout,
|
|
||||||
QComboBox,
|
|
||||||
QLineEdit,
|
|
||||||
QTableWidget,
|
|
||||||
QTableWidgetItem,
|
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QHeaderView,
|
QComboBox,
|
||||||
QPushButton,
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QMessageBox,
|
QFormLayout,
|
||||||
QWidget,
|
|
||||||
QFrame,
|
QFrame,
|
||||||
QToolButton,
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QStyle,
|
QStyle,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import strings
|
||||||
from .db import DBManager, DocumentRow
|
from .db import DBManager, DocumentRow
|
||||||
from .settings import load_db_config
|
from .settings import load_db_config
|
||||||
from .time_log import TimeCodeManagerDialog
|
from .time_log import TimeCodeManagerDialog
|
||||||
from . import strings
|
|
||||||
|
|
||||||
|
|
||||||
class TodaysDocumentsWidget(QFrame):
|
class TodaysDocumentsWidget(QFrame):
|
||||||
|
|
@ -112,7 +112,7 @@ class TodaysDocumentsWidget(QFrame):
|
||||||
if project_name:
|
if project_name:
|
||||||
extra_parts.append(project_name)
|
extra_parts.append(project_name)
|
||||||
if extra_parts:
|
if extra_parts:
|
||||||
label = f"{file_name} – " + " · ".join(extra_parts)
|
label = f"{file_name} - " + " · ".join(extra_parts)
|
||||||
|
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(
|
item.setData(
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
|
||||||
QShortcut,
|
|
||||||
QTextCursor,
|
|
||||||
QTextCharFormat,
|
|
||||||
QTextDocument,
|
|
||||||
)
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLineEdit,
|
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Fonts, only if the fonts are renamed to names not containing either
|
||||||
the words "Tavmjong Bah" or the word "Arev".
|
the words "Tavmjong Bah" or the word "Arev".
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts
|
This License becomes null and void to the extent applicable to Fonts
|
||||||
or Font Software that has been modified and is distributed under the
|
or Font Software that has been modified and is distributed under the
|
||||||
"Tavmjong Bah Arev" names.
|
"Tavmjong Bah Arev" names.
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ with others.
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
redistributed and/or sold with any software provided that any reserved
|
redistributed and/or sold with any software provided that any reserved
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
however, cannot be released under any other type of license. The
|
however, cannot be released under any other type of license. The
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib, re, html as _html
|
import difflib
|
||||||
|
import html as _html
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PySide6.QtCore import Qt, Slot
|
|
||||||
|
from PySide6.QtCore import QDate, Qt, Slot
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
QDialogButtonBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QPushButton,
|
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QTextBrowser,
|
QPushButton,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QAbstractItemView,
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
from .theme import ThemeManager
|
||||||
|
|
||||||
|
|
||||||
def _markdown_to_text(s: str) -> str:
|
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):
|
class HistoryDialog(QDialog):
|
||||||
"""Show versions for a date, preview, diff, and allow revert."""
|
"""Show versions for a date, preview, diff, and allow revert."""
|
||||||
|
|
||||||
def __init__(self, db, date_iso: str, parent=None):
|
def __init__(
|
||||||
|
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
|
||||||
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
||||||
self._db = db
|
self._db = db
|
||||||
self._date = date_iso
|
self._date = date_iso
|
||||||
|
self._themes = themes
|
||||||
self._versions = [] # list[dict] from DB
|
self._versions = [] # list[dict] from DB
|
||||||
self._current_id = None # id of current
|
self._current_id = None # id of current
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# --- Top: date label + change-date button
|
||||||
|
date_row = QHBoxLayout()
|
||||||
|
self.date_label = QLabel(strings._("date_label").format(date=date_iso))
|
||||||
|
date_row.addWidget(self.date_label)
|
||||||
|
date_row.addStretch(1)
|
||||||
|
self.change_date_btn = QPushButton(strings._("change_date"))
|
||||||
|
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
|
||||||
|
date_row.addWidget(self.change_date_btn)
|
||||||
|
root.addLayout(date_row)
|
||||||
|
|
||||||
# Top: list of versions
|
# Top: list of versions
|
||||||
top = QHBoxLayout()
|
top = QHBoxLayout()
|
||||||
self.list = QListWidget()
|
self.list = QListWidget()
|
||||||
|
|
@ -117,6 +137,53 @@ class HistoryDialog(QDialog):
|
||||||
|
|
||||||
self._load_versions()
|
self._load_versions()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _on_change_date_clicked(self) -> None:
|
||||||
|
"""Let the user choose a different date and reload entries."""
|
||||||
|
|
||||||
|
# Start from current dialog date; fall back to today if invalid
|
||||||
|
current_qdate = QDate.fromString(self._date, Qt.ISODate)
|
||||||
|
if not current_qdate.isValid():
|
||||||
|
current_qdate = QDate.currentDate()
|
||||||
|
|
||||||
|
dlg = QDialog(self)
|
||||||
|
dlg.setWindowTitle(strings._("select_date_title"))
|
||||||
|
|
||||||
|
layout = QVBoxLayout(dlg)
|
||||||
|
|
||||||
|
calendar = QCalendarWidget(dlg)
|
||||||
|
calendar.setSelectedDate(current_qdate)
|
||||||
|
layout.addWidget(calendar)
|
||||||
|
# Apply the same theming as the main sidebar calendar
|
||||||
|
if self._themes is not None:
|
||||||
|
self._themes.register_calendar(calendar)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(dlg.accept)
|
||||||
|
buttons.rejected.connect(dlg.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
new_qdate = calendar.selectedDate()
|
||||||
|
new_iso = new_qdate.toString(Qt.ISODate)
|
||||||
|
if new_iso == self._date:
|
||||||
|
# No change
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self._date = new_iso
|
||||||
|
|
||||||
|
# Update window title and header label
|
||||||
|
self.setWindowTitle(strings._("for").format(date=new_iso))
|
||||||
|
self.date_label.setText(strings._("date_label").format(date=new_iso))
|
||||||
|
|
||||||
|
# Reload entries for the newly selected date
|
||||||
|
self._load_versions()
|
||||||
|
|
||||||
# --- Data/UX helpers ---
|
# --- Data/UX helpers ---
|
||||||
def _load_versions(self):
|
def _load_versions(self):
|
||||||
# [{id,version_no,created_at,note,is_current}]
|
# [{id,version_no,created_at,note,is_current}]
|
||||||
|
|
|
||||||
1445
bouquin/invoices.py
Normal file
1445
bouquin/invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,13 +4,13 @@ from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QDialogButtonBox,
|
QVBoxLayout,
|
||||||
QFileDialog,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@
|
||||||
"backup_failed": "Backup failed",
|
"backup_failed": "Backup failed",
|
||||||
"quit": "Quit",
|
"quit": "Quit",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
|
|
@ -104,6 +103,7 @@
|
||||||
"autosave": "autosave",
|
"autosave": "autosave",
|
||||||
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
|
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
|
||||||
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
||||||
|
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
|
||||||
"insert_images": "Insert images",
|
"insert_images": "Insert images",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"reopen_failed": "Re-open failed",
|
"reopen_failed": "Re-open failed",
|
||||||
|
|
@ -155,6 +155,11 @@
|
||||||
"tag_already_exists_with_that_name": "A tag already exists with that name",
|
"tag_already_exists_with_that_name": "A tag already exists with that name",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"main_window_statistics_accessible_flag": "Stat&istics",
|
"main_window_statistics_accessible_flag": "Stat&istics",
|
||||||
|
"stats_group_pages": "Pages",
|
||||||
|
"stats_group_tags": "Tags",
|
||||||
|
"stats_group_documents": "Documents",
|
||||||
|
"stats_group_time_logging": "Time logging",
|
||||||
|
"stats_group_reminders": "Reminders",
|
||||||
"stats_pages_with_content": "Pages with content (current version)",
|
"stats_pages_with_content": "Pages with content (current version)",
|
||||||
"stats_total_revisions": "Total revisions",
|
"stats_total_revisions": "Total revisions",
|
||||||
"stats_page_most_revisions": "Page with most revisions",
|
"stats_page_most_revisions": "Page with most revisions",
|
||||||
|
|
@ -167,8 +172,16 @@
|
||||||
"stats_metric_revisions": "Revisions",
|
"stats_metric_revisions": "Revisions",
|
||||||
"stats_metric_documents": "Documents",
|
"stats_metric_documents": "Documents",
|
||||||
"stats_total_documents": "Total documents",
|
"stats_total_documents": "Total documents",
|
||||||
"stats_date_most_documents": "Date with most documents",
|
"stats_date_most_documents": "Date with most documents",
|
||||||
"stats_no_data": "No statistics available yet.",
|
"stats_no_data": "No statistics available yet.",
|
||||||
|
"stats_time_total_hours": "Total hours logged",
|
||||||
|
"stats_time_day_most_hours": "Day with most hours logged",
|
||||||
|
"stats_time_project_most_hours": "Project with most hours logged",
|
||||||
|
"stats_time_activity_most_hours": "Activity with most hours logged",
|
||||||
|
"stats_total_reminders": "Total reminders",
|
||||||
|
"stats_date_most_reminders": "Day with most reminders",
|
||||||
|
"stats_metric_hours": "Hours",
|
||||||
|
"stats_metric_reminders": "Reminders",
|
||||||
"select_notebook": "Select notebook",
|
"select_notebook": "Select notebook",
|
||||||
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
|
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
|
||||||
"bug_report_placeholder": "Type your bug report here",
|
"bug_report_placeholder": "Type your bug report here",
|
||||||
|
|
@ -196,11 +209,14 @@
|
||||||
"add_project": "Add project",
|
"add_project": "Add project",
|
||||||
"add_time_entry": "Add time entry",
|
"add_time_entry": "Add time entry",
|
||||||
"time_period": "Time period",
|
"time_period": "Time period",
|
||||||
|
"dont_group": "Don't group",
|
||||||
|
"by_activity": "by activity",
|
||||||
"by_day": "by day",
|
"by_day": "by day",
|
||||||
"by_month": "by month",
|
"by_month": "by month",
|
||||||
"by_week": "by week",
|
"by_week": "by week",
|
||||||
"date_range": "Date range",
|
"date_range": "Date range",
|
||||||
"custom_range": "Custom",
|
"custom_range": "Custom",
|
||||||
|
"last_week": "Last week",
|
||||||
"this_week": "This week",
|
"this_week": "This week",
|
||||||
"this_month": "This month",
|
"this_month": "This month",
|
||||||
"this_year": "This year",
|
"this_year": "This year",
|
||||||
|
|
@ -233,6 +249,8 @@
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"rename_activity": "Rename activity",
|
"rename_activity": "Rename activity",
|
||||||
"rename_project": "Rename project",
|
"rename_project": "Rename project",
|
||||||
|
"reporting": "Reporting",
|
||||||
|
"reporting_and_invoicing": "Reporting and Invoicing",
|
||||||
"run_report": "Run report",
|
"run_report": "Run report",
|
||||||
"add_activity_title": "Add activity",
|
"add_activity_title": "Add activity",
|
||||||
"add_activity_label": "Add an activity",
|
"add_activity_label": "Add an activity",
|
||||||
|
|
@ -248,10 +266,10 @@
|
||||||
"select_project_title": "Select project",
|
"select_project_title": "Select project",
|
||||||
"time_log": "Time log",
|
"time_log": "Time log",
|
||||||
"time_log_collapsed_hint": "Time log",
|
"time_log_collapsed_hint": "Time log",
|
||||||
"time_log_date_label": "Time log date: {date}",
|
"date_label": "Date: {date}",
|
||||||
"time_log_change_date": "Change date",
|
"change_date": "Change date",
|
||||||
"time_log_select_date_title": "Select time log date",
|
"select_date_title": "Select date",
|
||||||
"time_log_for": "Time log for {date}",
|
"for": "For {date}",
|
||||||
"time_log_no_date": "Time log",
|
"time_log_no_date": "Time log",
|
||||||
"time_log_no_entries": "No time entries yet",
|
"time_log_no_entries": "No time entries yet",
|
||||||
"time_log_report": "Time log report",
|
"time_log_report": "Time log report",
|
||||||
|
|
@ -274,6 +292,9 @@
|
||||||
"enable_tags_feature": "Enable Tags",
|
"enable_tags_feature": "Enable Tags",
|
||||||
"enable_time_log_feature": "Enable Time Logging",
|
"enable_time_log_feature": "Enable Time Logging",
|
||||||
"enable_reminders_feature": "Enable Reminders",
|
"enable_reminders_feature": "Enable Reminders",
|
||||||
|
"reminders_webhook_section_title": "Send Reminders to a webhook",
|
||||||
|
"reminders_webhook_url_label":"Webhook URL",
|
||||||
|
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
|
||||||
"enable_documents_feature": "Enable storing of documents",
|
"enable_documents_feature": "Enable storing of documents",
|
||||||
"pomodoro_time_log_default_text": "Focus session",
|
"pomodoro_time_log_default_text": "Focus session",
|
||||||
"toolbar_pomodoro_timer": "Time-logging timer",
|
"toolbar_pomodoro_timer": "Time-logging timer",
|
||||||
|
|
@ -303,7 +324,7 @@
|
||||||
"reminder": "Reminder",
|
"reminder": "Reminder",
|
||||||
"reminders": "Reminders",
|
"reminders": "Reminders",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"once_today": "Once (today)",
|
"once": "Once",
|
||||||
"every_day": "Every day",
|
"every_day": "Every day",
|
||||||
"every_weekday": "Every weekday (Mon-Fri)",
|
"every_weekday": "Every weekday (Mon-Fri)",
|
||||||
"every_week": "Every week",
|
"every_week": "Every week",
|
||||||
|
|
@ -356,7 +377,56 @@
|
||||||
"documents_missing_file": "The file does not exist:\n{path}",
|
"documents_missing_file": "The file does not exist:\n{path}",
|
||||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||||
"documents_search_label": "Search",
|
"documents_search_label": "Search",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
"todays_documents": "Documents from this day",
|
"todays_documents": "Documents from this day",
|
||||||
"todays_documents_none": "No documents yet."
|
"todays_documents_none": "No documents yet.",
|
||||||
|
"manage_invoices": "Manage Invoices",
|
||||||
|
"create_invoice": "Create Invoice",
|
||||||
|
"invoice_amount": "Amount",
|
||||||
|
"invoice_apply_tax": "Apply Tax",
|
||||||
|
"invoice_client_address": "Client Address",
|
||||||
|
"invoice_client_company": "Client Company",
|
||||||
|
"invoice_client_email": "Client E-mail",
|
||||||
|
"invoice_client_name": "Client Contact",
|
||||||
|
"invoice_currency": "Currency",
|
||||||
|
"invoice_dialog_title": "Create Invoice",
|
||||||
|
"invoice_due_date": "Due Date",
|
||||||
|
"invoice_hourly_rate": "Hourly Rate",
|
||||||
|
"invoice_hours": "Hours",
|
||||||
|
"invoice_issue_date": "Issue Date",
|
||||||
|
"invoice_mode_detailed": "Detailed mode",
|
||||||
|
"invoice_mode_summary": "Summary mode",
|
||||||
|
"invoice_number": "Invoice Number",
|
||||||
|
"invoice_save_and_export": "Save and export",
|
||||||
|
"invoice_save_pdf_title": "Save PDF",
|
||||||
|
"invoice_subtotal": "Subtotal",
|
||||||
|
"invoice_summary_default_desc": "Consultant services for the month of",
|
||||||
|
"invoice_summary_desc": "Summary description",
|
||||||
|
"invoice_summary_hours": "Summary hours",
|
||||||
|
"invoice_tax": "Tax details",
|
||||||
|
"invoice_tax_label": "Tax type",
|
||||||
|
"invoice_tax_rate": "Tax rate",
|
||||||
|
"invoice_tax_total": "Tax total",
|
||||||
|
"invoice_total": "Total",
|
||||||
|
"invoice_paid_at": "Paid on",
|
||||||
|
"invoice_payment_note": "Payment notes",
|
||||||
|
"invoice_project_required_title": "Project required",
|
||||||
|
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
|
||||||
|
"invoice_need_report_title": "Report required",
|
||||||
|
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
|
||||||
|
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
|
||||||
|
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
|
||||||
|
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
|
||||||
|
"invoice_company_profile": "Business Profile",
|
||||||
|
"invoice_company_name": "Business Name",
|
||||||
|
"invoice_company_address": "Address",
|
||||||
|
"invoice_company_phone": "Phone",
|
||||||
|
"invoice_company_email": "E-mail",
|
||||||
|
"invoice_company_tax_id": "Tax number",
|
||||||
|
"invoice_company_payment_details": "Payment details",
|
||||||
|
"invoice_company_logo": "Logo",
|
||||||
|
"invoice_company_logo_choose": "Choose logo",
|
||||||
|
"invoice_company_logo_set": "Logo has been set",
|
||||||
|
"invoice_company_logo_not_set": "Logo not set",
|
||||||
|
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@
|
||||||
"weekly": "hebdomadaire",
|
"weekly": "hebdomadaire",
|
||||||
"edit_reminder": "Modifier le rappel",
|
"edit_reminder": "Modifier le rappel",
|
||||||
"time": "Heure",
|
"time": "Heure",
|
||||||
"once_today": "Une fois (aujourd'hui)",
|
"once": "Une fois (aujourd'hui)",
|
||||||
"every_day": "Tous les jours",
|
"every_day": "Tous les jours",
|
||||||
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
||||||
"every_week": "Toutes les semaines",
|
"every_week": "Toutes les semaines",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QEvent
|
from PySide6.QtCore import QEvent, Qt
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
from PySide6.QtGui import QIcon
|
|
||||||
|
|
||||||
from .settings import APP_NAME, APP_ORG, get_settings
|
from PySide6.QtGui import QIcon
|
||||||
from .main_window import MainWindow
|
from PySide6.QtWidgets import QApplication
|
||||||
from .theme import Theme, ThemeConfig, ThemeManager
|
|
||||||
from . import strings
|
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():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,21 @@ from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QDate,
|
QDate,
|
||||||
QTimer,
|
|
||||||
Qt,
|
|
||||||
QSettings,
|
|
||||||
Slot,
|
|
||||||
QUrl,
|
|
||||||
QEvent,
|
|
||||||
QSignalBlocker,
|
|
||||||
QDateTime,
|
QDateTime,
|
||||||
|
QEvent,
|
||||||
|
QSettings,
|
||||||
|
QSignalBlocker,
|
||||||
|
Qt,
|
||||||
QTime,
|
QTime,
|
||||||
|
QTimer,
|
||||||
|
QUrl,
|
||||||
|
Slot,
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
|
|
@ -31,23 +31,24 @@ from PySide6.QtGui import (
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMenu,
|
QMenu,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QTableView,
|
QTableView,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QApplication,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import strings
|
||||||
from .bug_report_dialog import BugReportDialog
|
from .bug_report_dialog import BugReportDialog
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
||||||
|
|
@ -57,13 +58,12 @@ from .key_prompt import KeyPrompt
|
||||||
from .lock_overlay import LockOverlay
|
from .lock_overlay import LockOverlay
|
||||||
from .markdown_editor import MarkdownEditor
|
from .markdown_editor import MarkdownEditor
|
||||||
from .pomodoro_timer import PomodoroManager
|
from .pomodoro_timer import PomodoroManager
|
||||||
from .reminders import UpcomingRemindersWidget
|
from .reminders import UpcomingRemindersWidget, ReminderWebHook
|
||||||
from .save_dialog import SaveDialog
|
from .save_dialog import SaveDialog
|
||||||
from .search import Search
|
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 .settings_dialog import SettingsDialog
|
||||||
from .statistics_dialog import StatisticsDialog
|
from .statistics_dialog import StatisticsDialog
|
||||||
from . import strings
|
|
||||||
from .tags_widget import PageTagsWidget
|
from .tags_widget import PageTagsWidget
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager
|
||||||
from .time_log import TimeLogWidget
|
from .time_log import TimeLogWidget
|
||||||
|
|
@ -115,8 +115,12 @@ class MainWindow(QMainWindow):
|
||||||
self.tags.tagAdded.connect(self._on_tag_added)
|
self.tags.tagAdded.connect(self._on_tag_added)
|
||||||
|
|
||||||
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
||||||
|
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
|
||||||
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
||||||
|
|
||||||
|
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
|
||||||
|
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
|
||||||
|
|
||||||
self.pomodoro_manager = PomodoroManager(self.db, self)
|
self.pomodoro_manager = PomodoroManager(self.db, self)
|
||||||
|
|
||||||
# Lock the calendar to the left panel at the top to stop it stretching
|
# Lock the calendar to the left panel at the top to stop it stretching
|
||||||
|
|
@ -493,7 +497,7 @@ class MainWindow(QMainWindow):
|
||||||
idx = self._tab_index_for_date(date)
|
idx = self._tab_index_for_date(date)
|
||||||
if idx != -1:
|
if idx != -1:
|
||||||
self.tab_widget.setCurrentIndex(idx)
|
self.tab_widget.setCurrentIndex(idx)
|
||||||
# keep calendar selection in sync (don’t trigger load)
|
# keep calendar selection in sync (don't trigger load)
|
||||||
from PySide6.QtCore import QSignalBlocker
|
from PySide6.QtCore import QSignalBlocker
|
||||||
|
|
||||||
with QSignalBlocker(self.calendar):
|
with QSignalBlocker(self.calendar):
|
||||||
|
|
@ -516,7 +520,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
editor = MarkdownEditor(self.themes)
|
editor = MarkdownEditor(self.themes)
|
||||||
|
|
||||||
# Apply user’s preferred font size
|
# Apply user's preferred font size
|
||||||
self._apply_font_size(editor)
|
self._apply_font_size(editor)
|
||||||
|
|
||||||
# Set up the editor's event connections
|
# Set up the editor's event connections
|
||||||
|
|
@ -818,9 +822,13 @@ class MainWindow(QMainWindow):
|
||||||
Given a 'new day' (system date), return the date we should move
|
Given a 'new day' (system date), return the date we should move
|
||||||
unfinished todos *to*.
|
unfinished todos *to*.
|
||||||
|
|
||||||
If the new day is Saturday or Sunday, we skip ahead to the next Monday.
|
By default, if the new day is Saturday or Sunday we skip ahead to the
|
||||||
Otherwise we just return the same day.
|
next Monday (i.e., "next available weekday"). If the optional setting
|
||||||
|
`move_todos_include_weekends` is enabled, we move to the very next day
|
||||||
|
even if it's a weekend.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self.cfg, "move_todos_include_weekends", False):
|
||||||
|
return day
|
||||||
# Qt: Monday=1 ... Sunday=7
|
# Qt: Monday=1 ... Sunday=7
|
||||||
dow = day.dayOfWeek()
|
dow = day.dayOfWeek()
|
||||||
if dow >= 6: # Saturday (6) or Sunday (7)
|
if dow >= 6: # Saturday (6) or Sunday (7)
|
||||||
|
|
@ -875,7 +883,74 @@ class MainWindow(QMainWindow):
|
||||||
target_date = self._rollover_target_date(today)
|
target_date = self._rollover_target_date(today)
|
||||||
target_iso = target_date.toString("yyyy-MM-dd")
|
target_iso = target_date.toString("yyyy-MM-dd")
|
||||||
|
|
||||||
all_unchecked: list[str] = []
|
# Regexes for markdown headings and checkboxes
|
||||||
|
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
|
||||||
|
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
|
||||||
|
|
||||||
|
def _normalize_heading(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip trailing closing hashes and whitespace, e.g.
|
||||||
|
"## Foo ###" -> "Foo"
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
text = re.sub(r"\s+#+\s*$", "", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _insert_todos_under_heading(
|
||||||
|
target_lines: list[str],
|
||||||
|
heading_level: int,
|
||||||
|
heading_text: str,
|
||||||
|
todos: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Ensure a heading exists and append todos to the end of its section."""
|
||||||
|
normalized = _normalize_heading(heading_text)
|
||||||
|
|
||||||
|
# 1) Find existing heading with same text (any level)
|
||||||
|
start_idx = None
|
||||||
|
effective_level = None
|
||||||
|
for idx, line in enumerate(target_lines):
|
||||||
|
m = heading_re.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
level = len(m.group(1))
|
||||||
|
text = _normalize_heading(m.group(2))
|
||||||
|
if text == normalized:
|
||||||
|
start_idx = idx
|
||||||
|
effective_level = level
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2) If not found, create a new heading at the end
|
||||||
|
if start_idx is None:
|
||||||
|
if target_lines and target_lines[-1].strip():
|
||||||
|
target_lines.append("") # blank line before new heading
|
||||||
|
target_lines.append(f"{'#' * heading_level} {heading_text}")
|
||||||
|
start_idx = len(target_lines) - 1
|
||||||
|
effective_level = heading_level
|
||||||
|
|
||||||
|
# 3) Find the end of this heading's section
|
||||||
|
end_idx = len(target_lines)
|
||||||
|
for i in range(start_idx + 1, len(target_lines)):
|
||||||
|
m = heading_re.match(target_lines[i])
|
||||||
|
if m and len(m.group(1)) <= effective_level:
|
||||||
|
end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# 4) Insert before any trailing blank lines in the section
|
||||||
|
insert_at = end_idx
|
||||||
|
while (
|
||||||
|
insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
|
||||||
|
):
|
||||||
|
insert_at -= 1
|
||||||
|
|
||||||
|
for todo in todos:
|
||||||
|
target_lines.insert(insert_at, todo)
|
||||||
|
insert_at += 1
|
||||||
|
|
||||||
|
return target_lines
|
||||||
|
|
||||||
|
# Collect moved todos as (heading_info, item_text)
|
||||||
|
# heading_info is either None or (level, heading_text)
|
||||||
|
moved_items: list[tuple[tuple[int, str] | None, str]] = []
|
||||||
any_moved = False
|
any_moved = False
|
||||||
|
|
||||||
# Look back N days (yesterday = 1, up to `days_back`)
|
# Look back N days (yesterday = 1, up to `days_back`)
|
||||||
|
|
@ -889,14 +964,24 @@ class MainWindow(QMainWindow):
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
remaining_lines: list[str] = []
|
remaining_lines: list[str] = []
|
||||||
moved_from_this_day = False
|
moved_from_this_day = False
|
||||||
|
current_heading: tuple[int, str] | None = None
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
# Track the last seen heading (# / ## / ###)
|
||||||
|
m_head = heading_re.match(line)
|
||||||
|
if m_head:
|
||||||
|
level = len(m_head.group(1))
|
||||||
|
heading_text = _normalize_heading(m_head.group(2))
|
||||||
|
if level <= 3:
|
||||||
|
current_heading = (level, heading_text)
|
||||||
|
# Keep headings in the original day
|
||||||
|
remaining_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
|
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
|
||||||
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
if unchecked_re.match(line):
|
||||||
r"^\s*-\s*\[☐\]\s+", line
|
item_text = unchecked_re.sub("", line)
|
||||||
):
|
moved_items.append((current_heading, item_text))
|
||||||
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
|
||||||
all_unchecked.append(f"- [ ] {item_text}")
|
|
||||||
moved_from_this_day = True
|
moved_from_this_day = True
|
||||||
any_moved = True
|
any_moved = True
|
||||||
else:
|
else:
|
||||||
|
|
@ -914,9 +999,45 @@ class MainWindow(QMainWindow):
|
||||||
if not any_moved:
|
if not any_moved:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Append everything we collected to the *target* date
|
# --- Merge all moved items into the *target* date ---
|
||||||
unchecked_str = "\n".join(all_unchecked) + "\n"
|
|
||||||
self._load_selected_date(target_iso, unchecked_str)
|
target_text = self.db.get_entry(target_iso) or ""
|
||||||
|
target_lines = target_text.split("\n") if target_text else []
|
||||||
|
|
||||||
|
by_heading: dict[tuple[int, str], list[str]] = {}
|
||||||
|
plain_items: list[str] = []
|
||||||
|
|
||||||
|
for heading_info, item_text in moved_items:
|
||||||
|
todo_line = f"- [ ] {item_text}"
|
||||||
|
if heading_info is None:
|
||||||
|
# No heading above this checkbox in the source: behave as before
|
||||||
|
plain_items.append(todo_line)
|
||||||
|
else:
|
||||||
|
by_heading.setdefault(heading_info, []).append(todo_line)
|
||||||
|
|
||||||
|
# First insert all items that have headings
|
||||||
|
for (level, heading_text), todos in by_heading.items():
|
||||||
|
target_lines = _insert_todos_under_heading(
|
||||||
|
target_lines, level, heading_text, todos
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then append all items without headings at the end, like before
|
||||||
|
if plain_items:
|
||||||
|
if target_lines and target_lines[-1].strip():
|
||||||
|
target_lines.append("") # one blank line before the "unsectioned" todos
|
||||||
|
target_lines.extend(plain_items)
|
||||||
|
|
||||||
|
new_target_text = "\n".join(target_lines)
|
||||||
|
if not new_target_text.endswith("\n"):
|
||||||
|
new_target_text += "\n"
|
||||||
|
|
||||||
|
# Save the updated target date and load it into the editor
|
||||||
|
self.db.save_new_version(
|
||||||
|
target_iso,
|
||||||
|
new_target_text,
|
||||||
|
strings._("unchecked_checkbox_items_moved_to_next_day"),
|
||||||
|
)
|
||||||
|
self._load_selected_date(target_iso)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _on_date_changed(self):
|
def _on_date_changed(self):
|
||||||
|
|
@ -1219,6 +1340,11 @@ class MainWindow(QMainWindow):
|
||||||
# Turned off -> cancel any running timer and remove the widget
|
# Turned off -> cancel any running timer and remove the widget
|
||||||
self.pomodoro_manager.cancel_timer()
|
self.pomodoro_manager.cancel_timer()
|
||||||
|
|
||||||
|
def _send_reminder_webhook(self, text: str):
|
||||||
|
if self.cfg.reminders and self.cfg.reminders_webhook_url:
|
||||||
|
reminder_webhook = ReminderWebHook(text)
|
||||||
|
reminder_webhook._send()
|
||||||
|
|
||||||
def _show_flashing_reminder(self, text: str):
|
def _show_flashing_reminder(self, text: str):
|
||||||
"""
|
"""
|
||||||
Show a small flashing dialog and request attention from the OS.
|
Show a small flashing dialog and request attention from the OS.
|
||||||
|
|
@ -1351,7 +1477,7 @@ class MainWindow(QMainWindow):
|
||||||
else:
|
else:
|
||||||
date_iso = self._current_date_iso()
|
date_iso = self._current_date_iso()
|
||||||
|
|
||||||
dlg = HistoryDialog(self.db, date_iso, self)
|
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
|
||||||
if dlg.exec() == QDialog.Accepted:
|
if dlg.exec() == QDialog.Accepted:
|
||||||
# refresh editor + calendar (head pointer may have changed)
|
# refresh editor + calendar (head pointer may have changed)
|
||||||
self._load_selected_date(date_iso)
|
self._load_selected_date(date_iso)
|
||||||
|
|
@ -1444,10 +1570,22 @@ class MainWindow(QMainWindow):
|
||||||
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
||||||
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
|
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
|
||||||
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
||||||
|
self.cfg.move_todos_include_weekends = getattr(
|
||||||
|
new_cfg,
|
||||||
|
"move_todos_include_weekends",
|
||||||
|
getattr(self.cfg, "move_todos_include_weekends", False),
|
||||||
|
)
|
||||||
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
||||||
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
||||||
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
||||||
|
self.cfg.reminders_webhook_url = getattr(
|
||||||
|
new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
|
||||||
|
)
|
||||||
|
self.cfg.reminders_webhook_secret = getattr(
|
||||||
|
new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
|
||||||
|
)
|
||||||
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
|
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
|
||||||
|
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
|
||||||
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
||||||
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,28 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRect, Qt, QTimer, QUrl
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
QFontMetrics,
|
QFontMetrics,
|
||||||
QImage,
|
QImage,
|
||||||
QMouseEvent,
|
QMouseEvent,
|
||||||
QTextBlock,
|
QTextBlock,
|
||||||
|
QTextBlockFormat,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
QTextDocument,
|
QTextDocument,
|
||||||
QTextFormat,
|
QTextFormat,
|
||||||
QTextBlockFormat,
|
|
||||||
QTextImageFormat,
|
QTextImageFormat,
|
||||||
QDesktopServices,
|
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
|
|
||||||
from PySide6.QtWidgets import QDialog, QTextEdit
|
from PySide6.QtWidgets import QDialog, QTextEdit
|
||||||
|
|
||||||
from .theme import ThemeManager
|
|
||||||
from .markdown_highlighter import MarkdownHighlighter
|
|
||||||
from .code_block_editor_dialog import CodeBlockEditorDialog
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
from .code_block_editor_dialog import CodeBlockEditorDialog
|
||||||
|
from .markdown_highlighter import MarkdownHighlighter
|
||||||
|
from .theme import ThemeManager
|
||||||
|
|
||||||
|
|
||||||
class MarkdownEditor(QTextEdit):
|
class MarkdownEditor(QTextEdit):
|
||||||
|
|
@ -382,7 +382,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
cursor.removeSelectedText()
|
cursor.removeSelectedText()
|
||||||
cursor.insertText("\n" + new_text + "\n")
|
cursor.insertText("\n" + new_text + "\n")
|
||||||
else:
|
else:
|
||||||
# Empty block – keep one blank line inside the fences
|
# Empty block - keep one blank line inside the fences
|
||||||
cursor.removeSelectedText()
|
cursor.removeSelectedText()
|
||||||
cursor.insertText("\n\n")
|
cursor.insertText("\n\n")
|
||||||
cursor.endEditBlock()
|
cursor.endEditBlock()
|
||||||
|
|
@ -789,7 +789,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
"""
|
"""
|
||||||
# When the user is actively dragging with the mouse, we *do* want the
|
# When the user is actively dragging with the mouse, we *do* want the
|
||||||
# checkbox/bullet to be part of the selection (for deleting whole rows).
|
# checkbox/bullet to be part of the selection (for deleting whole rows).
|
||||||
# So don’t rewrite the selection in that case.
|
# So don't rewrite the selection in that case.
|
||||||
if getattr(self, "_mouse_drag_selecting", False):
|
if getattr(self, "_mouse_drag_selecting", False):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -863,7 +863,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
):
|
):
|
||||||
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
||||||
|
|
||||||
# Bullet list – Unicode bullet
|
# Bullet list - Unicode bullet
|
||||||
if line.startswith(f"{self._BULLET_DISPLAY} "):
|
if line.startswith(f"{self._BULLET_DISPLAY} "):
|
||||||
return ("bullet", f"{self._BULLET_DISPLAY} ")
|
return ("bullet", f"{self._BULLET_DISPLAY} ")
|
||||||
|
|
||||||
|
|
@ -1055,7 +1055,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
# of list prefixes (checkboxes / bullets / numbers).
|
# of list prefixes (checkboxes / bullets / numbers).
|
||||||
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
|
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
|
||||||
# Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
|
# Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
|
||||||
# document / word-left) – we don't interfere with those.
|
# document / word-left) - we don't interfere with those.
|
||||||
if event.modifiers() & Qt.ControlModifier:
|
if event.modifiers() & Qt.ControlModifier:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|
@ -1317,15 +1317,43 @@ class MarkdownEditor(QTextEdit):
|
||||||
if icon:
|
if icon:
|
||||||
# absolute document position of the icon
|
# absolute document position of the icon
|
||||||
doc_pos = block.position() + i
|
doc_pos = block.position() + i
|
||||||
r = char_rect_at(doc_pos, icon)
|
r_icon = char_rect_at(doc_pos, icon)
|
||||||
|
|
||||||
# ---------- Relax the hit area here ----------
|
# --- Find where the first non-space "real text" starts ---
|
||||||
# Expand the clickable area horizontally so you don't have to
|
first_idx = i + len(icon) + 1 # skip icon + trailing space
|
||||||
# land exactly on the glyph. This makes the "checkbox zone"
|
while first_idx < len(text) and text[first_idx].isspace():
|
||||||
# roughly 3× the glyph width, centered on it.
|
first_idx += 1
|
||||||
pad = r.width() # one glyph width on each side
|
|
||||||
hit_rect = r.adjusted(-pad, 0, pad, 0)
|
# Start with some padding around the icon itself
|
||||||
# ---------------------------------------------
|
left_pad = r_icon.width() // 2
|
||||||
|
right_pad = r_icon.width() // 2
|
||||||
|
|
||||||
|
hit_left = r_icon.left() - left_pad
|
||||||
|
|
||||||
|
# If there's actual text after the checkbox, clamp the
|
||||||
|
# clickable area so it stops *before* the first letter.
|
||||||
|
if first_idx < len(text):
|
||||||
|
first_doc_pos = block.position() + first_idx
|
||||||
|
c_first = QTextCursor(self.document())
|
||||||
|
c_first.setPosition(first_doc_pos)
|
||||||
|
first_x = self.cursorRect(c_first).x()
|
||||||
|
|
||||||
|
expanded_right = r_icon.right() + right_pad
|
||||||
|
hit_right = min(expanded_right, first_x)
|
||||||
|
else:
|
||||||
|
# No text after the checkbox on this line
|
||||||
|
hit_right = r_icon.right() + right_pad
|
||||||
|
|
||||||
|
# Make sure the rect is at least 1px wide
|
||||||
|
if hit_right <= hit_left:
|
||||||
|
hit_right = r_icon.right()
|
||||||
|
|
||||||
|
hit_rect = QRect(
|
||||||
|
hit_left,
|
||||||
|
r_icon.top(),
|
||||||
|
max(1, hit_right - hit_left),
|
||||||
|
r_icon.height(),
|
||||||
|
)
|
||||||
|
|
||||||
if hit_rect.contains(pt):
|
if hit_rect.contains(pt):
|
||||||
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
|
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
|
||||||
|
|
@ -1339,7 +1367,9 @@ class MarkdownEditor(QTextEdit):
|
||||||
edit.setPosition(doc_pos)
|
edit.setPosition(doc_pos)
|
||||||
# icon + space
|
# icon + space
|
||||||
edit.movePosition(
|
edit.movePosition(
|
||||||
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
|
QTextCursor.Right,
|
||||||
|
QTextCursor.KeepAnchor,
|
||||||
|
len(icon) + 1,
|
||||||
)
|
)
|
||||||
edit.insertText(f"{new_icon} ")
|
edit.insertText(f"{new_icon} ")
|
||||||
edit.endEditBlock()
|
edit.endEditBlock()
|
||||||
|
|
@ -1367,7 +1397,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
cursor = self.cursorForPosition(event.pos())
|
cursor = self.cursorForPosition(event.pos())
|
||||||
block = cursor.block()
|
block = cursor.block()
|
||||||
|
|
||||||
# If we’re on or inside a code block, open the editor instead
|
# If we're on or inside a code block, open the editor instead
|
||||||
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
|
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
|
||||||
# Only swallow the double-click if we actually opened a dialog.
|
# Only swallow the double-click if we actually opened a dialog.
|
||||||
if not self._edit_code_block(block):
|
if not self._edit_code_block(block):
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from PySide6.QtGui import (
|
||||||
QTextDocument,
|
QTextDocument,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .theme import ThemeManager, Theme
|
from .theme import Theme, ThemeManager
|
||||||
|
|
||||||
|
|
||||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ from typing import Optional
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ class PomodoroManager:
|
||||||
if hasattr(time_log_widget, "show_pomodoro_widget"):
|
if hasattr(time_log_widget, "show_pomodoro_widget"):
|
||||||
time_log_widget.show_pomodoro_widget(self._active_timer)
|
time_log_widget.show_pomodoro_widget(self._active_timer)
|
||||||
else:
|
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.setParent(time_log_widget)
|
||||||
self._active_timer.show()
|
self._active_timer.show()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,37 @@ from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QAbstractItemView,
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
|
||||||
QFormLayout,
|
|
||||||
QLineEdit,
|
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QTimeEdit,
|
QDateEdit,
|
||||||
QPushButton,
|
QDialog,
|
||||||
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QWidget,
|
QHBoxLayout,
|
||||||
QToolButton,
|
QHeaderView,
|
||||||
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QStyle,
|
|
||||||
QSizePolicy,
|
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStyle,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
QAbstractItemView,
|
QTimeEdit,
|
||||||
QHeaderView,
|
QToolButton,
|
||||||
QSpinBox,
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
|
from .settings import load_db_config
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class ReminderType(Enum):
|
class ReminderType(Enum):
|
||||||
|
|
@ -76,6 +80,22 @@ class ReminderDialog(QDialog):
|
||||||
self.text_edit.setText(reminder.text)
|
self.text_edit.setText(reminder.text)
|
||||||
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
|
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
|
||||||
|
|
||||||
|
# Date
|
||||||
|
self.date_edit = QDateEdit()
|
||||||
|
self.date_edit.setCalendarPopup(True)
|
||||||
|
self.date_edit.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
|
||||||
|
if reminder and reminder.date_iso:
|
||||||
|
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||||
|
if d.isValid():
|
||||||
|
self.date_edit.setDate(d)
|
||||||
|
else:
|
||||||
|
self.date_edit.setDate(QDate.currentDate())
|
||||||
|
else:
|
||||||
|
self.date_edit.setDate(QDate.currentDate())
|
||||||
|
|
||||||
|
self.form.addRow("&" + strings._("date") + ":", self.date_edit)
|
||||||
|
|
||||||
# Time
|
# Time
|
||||||
self.time_edit = QTimeEdit()
|
self.time_edit = QTimeEdit()
|
||||||
self.time_edit.setDisplayFormat("HH:mm")
|
self.time_edit.setDisplayFormat("HH:mm")
|
||||||
|
|
@ -90,7 +110,7 @@ class ReminderDialog(QDialog):
|
||||||
|
|
||||||
# Recurrence type
|
# Recurrence type
|
||||||
self.type_combo = QComboBox()
|
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_day"), ReminderType.DAILY)
|
||||||
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
||||||
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
||||||
|
|
@ -126,7 +146,7 @@ class ReminderDialog(QDialog):
|
||||||
if reminder and reminder.weekday is not None:
|
if reminder and reminder.weekday is not None:
|
||||||
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
||||||
else:
|
else:
|
||||||
self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
|
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
|
||||||
|
|
||||||
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
|
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
|
||||||
day_label = self.form.labelForField(self.weekday_combo)
|
day_label = self.form.labelForField(self.weekday_combo)
|
||||||
|
|
@ -187,6 +207,16 @@ class ReminderDialog(QDialog):
|
||||||
self.nth_spin.setVisible(show_nth)
|
self.nth_spin.setVisible(show_nth)
|
||||||
nth_label.setVisible(show_nth)
|
nth_label.setVisible(show_nth)
|
||||||
|
|
||||||
|
# For new reminders, when switching to a type that uses a weekday,
|
||||||
|
# snap the weekday to match the currently selected date.
|
||||||
|
if reminder_type in (
|
||||||
|
ReminderType.WEEKLY,
|
||||||
|
ReminderType.MONTHLY_NTH_WEEKDAY,
|
||||||
|
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
|
||||||
|
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
|
||||||
|
if 0 <= dow < self.weekday_combo.count():
|
||||||
|
self.weekday_combo.setCurrentIndex(dow)
|
||||||
|
|
||||||
def get_reminder(self) -> Reminder:
|
def get_reminder(self) -> Reminder:
|
||||||
"""Get the configured reminder."""
|
"""Get the configured reminder."""
|
||||||
reminder_type = self.type_combo.currentData()
|
reminder_type = self.type_combo.currentData()
|
||||||
|
|
@ -198,46 +228,32 @@ class ReminderDialog(QDialog):
|
||||||
weekday = self.weekday_combo.currentData()
|
weekday = self.weekday_combo.currentData()
|
||||||
|
|
||||||
date_iso = None
|
date_iso = None
|
||||||
today = QDate.currentDate()
|
anchor_date = self.date_edit.date()
|
||||||
|
|
||||||
if reminder_type == ReminderType.ONCE:
|
if reminder_type == ReminderType.ONCE:
|
||||||
# Fire once, today, at the chosen time
|
# Fire once, on the chosen calendar date at the chosen time
|
||||||
date_iso = today.toString("yyyy-MM-dd")
|
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||||
|
|
||||||
elif reminder_type == ReminderType.FORTNIGHTLY:
|
elif reminder_type == ReminderType.FORTNIGHTLY:
|
||||||
# Anchor: today. Every 14 days from this date.
|
# Anchor: the chosen calendar date. Every 14 days from this date.
|
||||||
if (
|
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||||
self._reminder
|
|
||||||
and self._reminder.reminder_type == ReminderType.FORTNIGHTLY
|
|
||||||
and self._reminder.date_iso
|
|
||||||
):
|
|
||||||
date_iso = self._reminder.date_iso
|
|
||||||
else:
|
|
||||||
date_iso = today.toString("yyyy-MM-dd")
|
|
||||||
|
|
||||||
elif reminder_type == ReminderType.MONTHLY_DATE:
|
elif reminder_type == ReminderType.MONTHLY_DATE:
|
||||||
# Anchor: today's calendar date. "Same date each month"
|
# Anchor: the chosen calendar date. "Same date each month"
|
||||||
if (
|
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||||
self._reminder
|
|
||||||
and self._reminder.reminder_type == ReminderType.MONTHLY_DATE
|
|
||||||
and self._reminder.date_iso
|
|
||||||
):
|
|
||||||
date_iso = self._reminder.date_iso
|
|
||||||
else:
|
|
||||||
date_iso = today.toString("yyyy-MM-dd")
|
|
||||||
|
|
||||||
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||||
# Anchor: the nth weekday for this month (gives us “3rd Monday” etc.)
|
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
|
||||||
weekday = self.weekday_combo.currentData()
|
weekday = self.weekday_combo.currentData()
|
||||||
nth_index = self.nth_spin.value() - 1 # 0-based
|
nth_index = self.nth_spin.value() - 1 # 0-based
|
||||||
|
|
||||||
first = QDate(today.year(), today.month(), 1)
|
first = QDate(anchor_date.year(), anchor_date.month(), 1)
|
||||||
target_dow = weekday + 1 # Qt: Monday=1
|
target_dow = weekday + 1 # Qt: Monday=1
|
||||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||||
anchor = first.addDays(offset + nth_index * 7)
|
anchor = first.addDays(offset + nth_index * 7)
|
||||||
|
|
||||||
# If nth weekday doesn't exist in this month, fall back to the last such weekday
|
# If nth weekday doesn't exist in this month, fall back to the last such weekday
|
||||||
if anchor.month() != today.month():
|
if anchor.month() != anchor_date.month():
|
||||||
anchor = anchor.addDays(-7)
|
anchor = anchor.addDays(-7)
|
||||||
|
|
||||||
date_iso = anchor.toString("yyyy-MM-dd")
|
date_iso = anchor.toString("yyyy-MM-dd")
|
||||||
|
|
@ -319,43 +335,36 @@ class UpcomingRemindersWidget(QFrame):
|
||||||
main.addWidget(self.body)
|
main.addWidget(self.body)
|
||||||
|
|
||||||
# Timer to check and fire reminders
|
# Timer to check and fire reminders
|
||||||
# Start by syncing to the next minute boundary
|
#
|
||||||
self._check_timer = QTimer(self)
|
# We tick once per second, but only hit the DB when the clock is
|
||||||
self._check_timer.timeout.connect(self._check_reminders)
|
# exactly on a :00 second. That way a reminder for HH:MM fires at
|
||||||
|
# HH:MM:00, independent of when it was created.
|
||||||
|
self._tick_timer = QTimer(self)
|
||||||
|
self._tick_timer.setInterval(1000) # 1 second
|
||||||
|
self._tick_timer.timeout.connect(self._on_tick)
|
||||||
|
self._tick_timer.start()
|
||||||
|
|
||||||
# Calculate milliseconds until next minute (HH:MM:00)
|
# Also check once on startup so we don't miss reminders that
|
||||||
|
# should have fired a moment ago when the app wasn't running.
|
||||||
|
QTimer.singleShot(0, self._check_reminders)
|
||||||
|
|
||||||
|
def _on_tick(self) -> None:
|
||||||
|
"""Called every second; run reminder check only on exact minute boundaries."""
|
||||||
now = QDateTime.currentDateTime()
|
now = QDateTime.currentDateTime()
|
||||||
current_second = now.time().second()
|
if now.time().second() == 0:
|
||||||
current_msec = now.time().msec()
|
# Only do the heavier DB work once per minute, at HH:MM:00,
|
||||||
|
# so reminders are aligned to the clock and not to when they
|
||||||
# Milliseconds until next minute
|
# were created.
|
||||||
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
|
self._check_reminders(now)
|
||||||
|
|
||||||
# Start with a single-shot to sync to the minute
|
|
||||||
self._sync_timer = QTimer(self)
|
|
||||||
self._sync_timer.setSingleShot(True)
|
|
||||||
self._sync_timer.timeout.connect(self._start_regular_timer)
|
|
||||||
self._sync_timer.start(ms_until_next_minute)
|
|
||||||
|
|
||||||
# Also check immediately in case there are pending reminders
|
|
||||||
QTimer.singleShot(1000, self._check_reminders)
|
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""Cleanup timers when widget is destroyed."""
|
"""Cleanup timers when widget is destroyed."""
|
||||||
try:
|
try:
|
||||||
if hasattr(self, "_check_timer") and self._check_timer:
|
if hasattr(self, "_tick_timer") and self._tick_timer:
|
||||||
self._check_timer.stop()
|
self._tick_timer.stop()
|
||||||
if hasattr(self, "_sync_timer") and self._sync_timer:
|
except Exception:
|
||||||
self._sync_timer.stop()
|
|
||||||
except:
|
|
||||||
pass # Ignore any cleanup errors
|
pass # Ignore any cleanup errors
|
||||||
|
|
||||||
def _start_regular_timer(self):
|
|
||||||
"""Start the regular check timer after initial sync."""
|
|
||||||
# Now we're at a minute boundary, check and start regular timer
|
|
||||||
self._check_reminders()
|
|
||||||
self._check_timer.start(60000) # Check every minute
|
|
||||||
|
|
||||||
def _on_toggle(self, checked: bool):
|
def _on_toggle(self, checked: bool):
|
||||||
"""Toggle visibility of reminder list."""
|
"""Toggle visibility of reminder list."""
|
||||||
self.body.setVisible(checked)
|
self.body.setVisible(checked)
|
||||||
|
|
@ -471,7 +480,7 @@ class UpcomingRemindersWidget(QFrame):
|
||||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||||
candidate = first.addDays(offset + anchor_n * 7)
|
candidate = first.addDays(offset + anchor_n * 7)
|
||||||
|
|
||||||
# If that nth weekday doesn’t 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():
|
if candidate.month() != date.month():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -479,21 +488,28 @@ class UpcomingRemindersWidget(QFrame):
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _check_reminders(self):
|
def _check_reminders(self, now: QDateTime | None = None):
|
||||||
"""Check if any reminders should fire now."""
|
"""
|
||||||
|
Check and trigger due reminders.
|
||||||
|
|
||||||
|
This uses absolute clock time, so a reminder for HH:MM will fire
|
||||||
|
when the system clock reaches HH:MM:00, independent of when the
|
||||||
|
reminder was created.
|
||||||
|
"""
|
||||||
# Guard: Check if database connection is valid
|
# Guard: Check if database connection is valid
|
||||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
now = QDateTime.currentDateTime()
|
if now is None:
|
||||||
today = QDate.currentDate()
|
now = QDateTime.currentDateTime()
|
||||||
|
|
||||||
# Round current time to the minute (set seconds to 0)
|
|
||||||
current_minute = QDateTime(
|
|
||||||
today, QTime(now.time().hour(), now.time().minute(), 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
today = now.date()
|
||||||
reminders = self._db.get_all_reminders()
|
reminders = self._db.get_all_reminders()
|
||||||
|
|
||||||
|
# Small grace window (in seconds) so we still fire reminders if
|
||||||
|
# the app was just opened or the event loop was briefly busy.
|
||||||
|
GRACE_WINDOW_SECS = 120 # 2 minutes
|
||||||
|
|
||||||
for reminder in reminders:
|
for reminder in reminders:
|
||||||
if not reminder.active:
|
if not reminder.active:
|
||||||
continue
|
continue
|
||||||
|
|
@ -501,28 +517,35 @@ class UpcomingRemindersWidget(QFrame):
|
||||||
if not self._should_fire_on_date(reminder, today):
|
if not self._should_fire_on_date(reminder, today):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse time
|
# Parse time: stored as "HH:MM", we treat that as HH:MM:00
|
||||||
hour, minute = map(int, reminder.time_str.split(":"))
|
hour, minute = map(int, reminder.time_str.split(":"))
|
||||||
target = QDateTime(today, QTime(hour, minute, 0))
|
target = QDateTime(today, QTime(hour, minute, 0))
|
||||||
|
|
||||||
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
|
# Skip if this reminder is still in the future
|
||||||
seconds_diff = current_minute.secsTo(target)
|
if now < target:
|
||||||
if -120 <= seconds_diff <= 0:
|
continue
|
||||||
# Check if we haven't already fired this one
|
|
||||||
|
# How long ago should this reminder have fired?
|
||||||
|
seconds_late = target.secsTo(now) # target -> now
|
||||||
|
|
||||||
|
if 0 <= seconds_late <= GRACE_WINDOW_SECS:
|
||||||
|
# Check if we haven't already fired this occurrence
|
||||||
if not hasattr(self, "_fired_reminders"):
|
if not hasattr(self, "_fired_reminders"):
|
||||||
self._fired_reminders = {}
|
self._fired_reminders = {}
|
||||||
|
|
||||||
reminder_key = (reminder.id, target.toString())
|
reminder_key = (reminder.id, target.toString())
|
||||||
|
|
||||||
# Only fire once per reminder per target time
|
if reminder_key in self._fired_reminders:
|
||||||
if reminder_key not in self._fired_reminders:
|
continue
|
||||||
self._fired_reminders[reminder_key] = current_minute
|
|
||||||
self.reminderTriggered.emit(reminder.text)
|
|
||||||
|
|
||||||
# For ONCE reminders, deactivate after firing
|
# Mark as fired and emit
|
||||||
if reminder.reminder_type == ReminderType.ONCE:
|
self._fired_reminders[reminder_key] = now
|
||||||
self._db.update_reminder_active(reminder.id, False)
|
self.reminderTriggered.emit(reminder.text)
|
||||||
self.refresh() # Refresh the list to show deactivated reminder
|
|
||||||
|
# For ONCE reminders, deactivate after firing
|
||||||
|
if reminder.reminder_type == ReminderType.ONCE:
|
||||||
|
self._db.update_reminder_active(reminder.id, False)
|
||||||
|
self.refresh() # Refresh the list to show deactivated reminder
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def _add_reminder(self):
|
def _add_reminder(self):
|
||||||
|
|
@ -553,8 +576,8 @@ class UpcomingRemindersWidget(QFrame):
|
||||||
if not selected_items:
|
if not selected_items:
|
||||||
return
|
return
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMenu
|
|
||||||
from PySide6.QtGui import QAction
|
from PySide6.QtGui import QAction
|
||||||
|
from PySide6.QtWidgets import QMenu
|
||||||
|
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
|
||||||
|
|
@ -683,10 +706,11 @@ class ManageRemindersDialog(QDialog):
|
||||||
|
|
||||||
# Reminder list table
|
# Reminder list table
|
||||||
self.table = QTableWidget()
|
self.table = QTableWidget()
|
||||||
self.table.setColumnCount(5)
|
self.table.setColumnCount(6)
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
[
|
[
|
||||||
strings._("text"),
|
strings._("text"),
|
||||||
|
strings._("date"),
|
||||||
strings._("time"),
|
strings._("time"),
|
||||||
strings._("type"),
|
strings._("type"),
|
||||||
strings._("active"),
|
strings._("active"),
|
||||||
|
|
@ -732,12 +756,24 @@ class ManageRemindersDialog(QDialog):
|
||||||
text_item.setData(Qt.UserRole, reminder)
|
text_item.setData(Qt.UserRole, reminder)
|
||||||
self.table.setItem(row, 0, text_item)
|
self.table.setItem(row, 0, text_item)
|
||||||
|
|
||||||
|
# Date
|
||||||
|
date_display = ""
|
||||||
|
if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
|
||||||
|
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||||
|
if d.isValid():
|
||||||
|
date_display = d.toString("yyyy-MM-dd")
|
||||||
|
else:
|
||||||
|
date_display = reminder.date_iso
|
||||||
|
|
||||||
|
date_item = QTableWidgetItem(date_display)
|
||||||
|
self.table.setItem(row, 1, date_item)
|
||||||
|
|
||||||
# Time
|
# Time
|
||||||
time_item = QTableWidgetItem(reminder.time_str)
|
time_item = QTableWidgetItem(reminder.time_str)
|
||||||
self.table.setItem(row, 1, time_item)
|
self.table.setItem(row, 2, time_item)
|
||||||
|
|
||||||
# Type
|
# Type
|
||||||
type_str = {
|
base_type_strs = {
|
||||||
ReminderType.ONCE: "Once",
|
ReminderType.ONCE: "Once",
|
||||||
ReminderType.DAILY: "Daily",
|
ReminderType.DAILY: "Daily",
|
||||||
ReminderType.WEEKDAYS: "Weekdays",
|
ReminderType.WEEKDAYS: "Weekdays",
|
||||||
|
|
@ -745,35 +781,63 @@ class ManageRemindersDialog(QDialog):
|
||||||
ReminderType.FORTNIGHTLY: "Fortnightly",
|
ReminderType.FORTNIGHTLY: "Fortnightly",
|
||||||
ReminderType.MONTHLY_DATE: "Monthly (date)",
|
ReminderType.MONTHLY_DATE: "Monthly (date)",
|
||||||
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
|
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
|
||||||
}.get(reminder.reminder_type, "Unknown")
|
}
|
||||||
|
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
|
||||||
|
|
||||||
# Add day-of-week annotation where it makes sense
|
# Short day names we can reuse
|
||||||
if (
|
days_short = [
|
||||||
reminder.reminder_type
|
strings._("monday_short"),
|
||||||
in (
|
strings._("tuesday_short"),
|
||||||
ReminderType.WEEKLY,
|
strings._("wednesday_short"),
|
||||||
ReminderType.FORTNIGHTLY,
|
strings._("thursday_short"),
|
||||||
ReminderType.MONTHLY_NTH_WEEKDAY,
|
strings._("friday_short"),
|
||||||
)
|
strings._("saturday_short"),
|
||||||
and reminder.weekday is not None
|
strings._("sunday_short"),
|
||||||
):
|
]
|
||||||
days = [
|
|
||||||
strings._("monday_short"),
|
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||||
strings._("tuesday_short"),
|
# Show something like: Monthly (3rd Mon)
|
||||||
strings._("wednesday_short"),
|
day_name = ""
|
||||||
strings._("thursday_short"),
|
if reminder.weekday is not None and 0 <= reminder.weekday < len(
|
||||||
strings._("friday_short"),
|
days_short
|
||||||
strings._("saturday_short"),
|
):
|
||||||
strings._("sunday_short"),
|
day_name = days_short[reminder.weekday]
|
||||||
]
|
|
||||||
type_str += f" ({days[reminder.weekday]})"
|
nth_label = ""
|
||||||
|
if reminder.date_iso:
|
||||||
|
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||||
|
if anchor.isValid():
|
||||||
|
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
|
||||||
|
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
|
||||||
|
if 0 <= nth_index < len(ordinals):
|
||||||
|
nth_label = ordinals[nth_index]
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if nth_label:
|
||||||
|
parts.append(nth_label)
|
||||||
|
if day_name:
|
||||||
|
parts.append(day_name)
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
type_str = f"Monthly ({' '.join(parts)})"
|
||||||
|
# else: fall back to the generic "Monthly (nth weekday)"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For weekly / fortnightly types, still append the day name
|
||||||
|
if (
|
||||||
|
reminder.reminder_type
|
||||||
|
in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
|
||||||
|
and reminder.weekday is not None
|
||||||
|
and 0 <= reminder.weekday < len(days_short)
|
||||||
|
):
|
||||||
|
type_str += f" ({days_short[reminder.weekday]})"
|
||||||
|
|
||||||
type_item = QTableWidgetItem(type_str)
|
type_item = QTableWidgetItem(type_str)
|
||||||
self.table.setItem(row, 2, type_item)
|
self.table.setItem(row, 3, type_item)
|
||||||
|
|
||||||
# Active
|
# Active
|
||||||
active_item = QTableWidgetItem("✓" if reminder.active else "✗")
|
active_item = QTableWidgetItem("✓" if reminder.active else "✗")
|
||||||
self.table.setItem(row, 3, active_item)
|
self.table.setItem(row, 4, active_item)
|
||||||
|
|
||||||
# Actions
|
# Actions
|
||||||
actions_widget = QWidget()
|
actions_widget = QWidget()
|
||||||
|
|
@ -790,7 +854,7 @@ class ManageRemindersDialog(QDialog):
|
||||||
)
|
)
|
||||||
actions_layout.addWidget(delete_btn)
|
actions_layout.addWidget(delete_btn)
|
||||||
|
|
||||||
self.table.setCellWidget(row, 4, actions_widget)
|
self.table.setCellWidget(row, 5, actions_widget)
|
||||||
|
|
||||||
def _add_reminder(self):
|
def _add_reminder(self):
|
||||||
"""Add a new reminder."""
|
"""Add a new reminder."""
|
||||||
|
|
@ -821,3 +885,33 @@ class ManageRemindersDialog(QDialog):
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
self._db.delete_reminder(reminder.id)
|
self._db.delete_reminder(reminder.id)
|
||||||
self._load_reminders()
|
self._load_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderWebHook:
|
||||||
|
def __init__(self, text):
|
||||||
|
self.text = text
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
|
||||||
|
def _send(self):
|
||||||
|
payload: dict[str, str] = {
|
||||||
|
"reminder": self.text,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = self.cfg.reminders_webhook_url
|
||||||
|
secret = self.cfg.reminders_webhook_secret
|
||||||
|
|
||||||
|
_headers = {}
|
||||||
|
if secret:
|
||||||
|
_headers["X-Bouquin-Secret"] = secret
|
||||||
|
|
||||||
|
if url:
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
timeout=10,
|
||||||
|
headers=_headers,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# We did our best
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,7 @@ from __future__ import annotations
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from PySide6.QtGui import QFontMetrics
|
from PySide6.QtGui import QFontMetrics
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
|
||||||
QDialog,
|
|
||||||
QVBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QLineEdit,
|
|
||||||
QDialogButtonBox,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ from typing import Iterable, Tuple
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QHBoxLayout,
|
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QSettings, QStandardPaths
|
from PySide6.QtCore import QSettings, QStandardPaths
|
||||||
|
|
||||||
from .db import DBConfig
|
from .db import DBConfig
|
||||||
|
|
@ -41,10 +42,16 @@ def load_db_config() -> DBConfig:
|
||||||
idle = s.value("ui/idle_minutes", 15, type=int)
|
idle = s.value("ui/idle_minutes", 15, type=int)
|
||||||
theme = s.value("ui/theme", "system", type=str)
|
theme = s.value("ui/theme", "system", type=str)
|
||||||
move_todos = s.value("ui/move_todos", False, type=bool)
|
move_todos = s.value("ui/move_todos", False, type=bool)
|
||||||
|
move_todos_include_weekends = s.value(
|
||||||
|
"ui/move_todos_include_weekends", False, type=bool
|
||||||
|
)
|
||||||
tags = s.value("ui/tags", True, type=bool)
|
tags = s.value("ui/tags", True, type=bool)
|
||||||
time_log = s.value("ui/time_log", True, type=bool)
|
time_log = s.value("ui/time_log", True, type=bool)
|
||||||
reminders = s.value("ui/reminders", True, type=bool)
|
reminders = s.value("ui/reminders", True, type=bool)
|
||||||
|
reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str)
|
||||||
|
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
|
||||||
documents = s.value("ui/documents", True, type=bool)
|
documents = s.value("ui/documents", True, type=bool)
|
||||||
|
invoicing = s.value("ui/invoicing", False, type=bool)
|
||||||
locale = s.value("ui/locale", "en", type=str)
|
locale = s.value("ui/locale", "en", type=str)
|
||||||
font_size = s.value("ui/font_size", 11, type=int)
|
font_size = s.value("ui/font_size", 11, type=int)
|
||||||
return DBConfig(
|
return DBConfig(
|
||||||
|
|
@ -53,10 +60,14 @@ def load_db_config() -> DBConfig:
|
||||||
idle_minutes=idle,
|
idle_minutes=idle,
|
||||||
theme=theme,
|
theme=theme,
|
||||||
move_todos=move_todos,
|
move_todos=move_todos,
|
||||||
|
move_todos_include_weekends=move_todos_include_weekends,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
time_log=time_log,
|
time_log=time_log,
|
||||||
reminders=reminders,
|
reminders=reminders,
|
||||||
|
reminders_webhook_url=reminders_webhook_url,
|
||||||
|
reminders_webhook_secret=reminders_webhook_secret,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
invoicing=invoicing,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
font_size=font_size,
|
font_size=font_size,
|
||||||
)
|
)
|
||||||
|
|
@ -69,9 +80,13 @@ def save_db_config(cfg: DBConfig) -> None:
|
||||||
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
||||||
s.setValue("ui/theme", str(cfg.theme))
|
s.setValue("ui/theme", str(cfg.theme))
|
||||||
s.setValue("ui/move_todos", str(cfg.move_todos))
|
s.setValue("ui/move_todos", str(cfg.move_todos))
|
||||||
|
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
|
||||||
s.setValue("ui/tags", str(cfg.tags))
|
s.setValue("ui/tags", str(cfg.tags))
|
||||||
s.setValue("ui/time_log", str(cfg.time_log))
|
s.setValue("ui/time_log", str(cfg.time_log))
|
||||||
s.setValue("ui/reminders", str(cfg.reminders))
|
s.setValue("ui/reminders", str(cfg.reminders))
|
||||||
|
s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url))
|
||||||
|
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
|
||||||
s.setValue("ui/documents", str(cfg.documents))
|
s.setValue("ui/documents", str(cfg.documents))
|
||||||
|
s.setValue("ui/invoicing", str(cfg.invoicing))
|
||||||
s.setValue("ui/locale", str(cfg.locale))
|
s.setValue("ui/locale", str(cfg.locale))
|
||||||
s.setValue("ui/font_size", str(cfg.font_size))
|
s.setValue("ui/font_size", str(cfg.font_size))
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,37 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QLabel,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QDialogButtonBox,
|
|
||||||
QRadioButton,
|
QRadioButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QMessageBox,
|
|
||||||
QWidget,
|
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
|
QTextEdit,
|
||||||
|
QToolButton,
|
||||||
|
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 . 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):
|
class SettingsDialog(QDialog):
|
||||||
|
|
@ -42,7 +45,7 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
self.current_settings = load_db_config()
|
self.current_settings = load_db_config()
|
||||||
|
|
||||||
self.setMinimumWidth(480)
|
self.setMinimumWidth(600)
|
||||||
self.setSizeGripEnabled(True)
|
self.setSizeGripEnabled(True)
|
||||||
|
|
||||||
# --- Tabs ----------------------------------------------------------
|
# --- Tabs ----------------------------------------------------------
|
||||||
|
|
@ -166,6 +169,25 @@ class SettingsDialog(QDialog):
|
||||||
self.move_todos.setCursor(Qt.PointingHandCursor)
|
self.move_todos.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.move_todos)
|
features_layout.addWidget(self.move_todos)
|
||||||
|
|
||||||
|
# Optional: allow moving to the very next day even if it is a weekend.
|
||||||
|
self.move_todos_include_weekends = QCheckBox(
|
||||||
|
strings._("move_todos_include_weekends")
|
||||||
|
)
|
||||||
|
self.move_todos_include_weekends.setChecked(
|
||||||
|
getattr(self.current_settings, "move_todos_include_weekends", False)
|
||||||
|
)
|
||||||
|
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
|
||||||
|
|
||||||
|
move_todos_opts = QWidget()
|
||||||
|
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
|
||||||
|
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
|
||||||
|
move_todos_opts_layout.setSpacing(4)
|
||||||
|
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
|
||||||
|
features_layout.addWidget(move_todos_opts)
|
||||||
|
|
||||||
|
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
|
||||||
|
|
||||||
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
||||||
self.tags.setChecked(self.current_settings.tags)
|
self.tags.setChecked(self.current_settings.tags)
|
||||||
self.tags.setCursor(Qt.PointingHandCursor)
|
self.tags.setCursor(Qt.PointingHandCursor)
|
||||||
|
|
@ -176,17 +198,145 @@ class SettingsDialog(QDialog):
|
||||||
self.time_log.setCursor(Qt.PointingHandCursor)
|
self.time_log.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.time_log)
|
features_layout.addWidget(self.time_log)
|
||||||
|
|
||||||
|
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
|
||||||
|
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
|
||||||
|
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
|
||||||
|
self.invoicing.setCursor(Qt.PointingHandCursor)
|
||||||
|
features_layout.addWidget(self.invoicing)
|
||||||
|
# Invoicing only if time_log is enabled
|
||||||
|
if not self.current_settings.time_log:
|
||||||
|
self.invoicing.setChecked(False)
|
||||||
|
self.invoicing.setEnabled(False)
|
||||||
|
self.time_log.toggled.connect(self._on_time_log_toggled)
|
||||||
|
|
||||||
|
# --- Reminders feature + webhook options -------------------------
|
||||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||||
self.reminders.setChecked(self.current_settings.reminders)
|
self.reminders.setChecked(self.current_settings.reminders)
|
||||||
|
self.reminders.toggled.connect(self._on_reminders_toggled)
|
||||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.reminders)
|
features_layout.addWidget(self.reminders)
|
||||||
|
|
||||||
|
# Container for reminder-specific options, indented under the checkbox
|
||||||
|
self.reminders_options_container = QWidget()
|
||||||
|
reminders_options_layout = QVBoxLayout(self.reminders_options_container)
|
||||||
|
reminders_options_layout.setContentsMargins(24, 0, 0, 0)
|
||||||
|
reminders_options_layout.setSpacing(4)
|
||||||
|
|
||||||
|
self.reminders_options_toggle = QToolButton()
|
||||||
|
self.reminders_options_toggle.setText(
|
||||||
|
strings._("reminders_webhook_section_title")
|
||||||
|
)
|
||||||
|
self.reminders_options_toggle.setCheckable(True)
|
||||||
|
self.reminders_options_toggle.setChecked(False)
|
||||||
|
self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||||
|
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
|
||||||
|
self.reminders_options_toggle.clicked.connect(
|
||||||
|
self._on_reminders_options_toggled
|
||||||
|
)
|
||||||
|
|
||||||
|
toggle_row = QHBoxLayout()
|
||||||
|
toggle_row.addWidget(self.reminders_options_toggle)
|
||||||
|
toggle_row.addStretch()
|
||||||
|
reminders_options_layout.addLayout(toggle_row)
|
||||||
|
|
||||||
|
# Actual options (labels + QLineEdits)
|
||||||
|
self.reminders_options_widget = QWidget()
|
||||||
|
options_form = QFormLayout(self.reminders_options_widget)
|
||||||
|
options_form.setContentsMargins(0, 0, 0, 0)
|
||||||
|
options_form.setSpacing(4)
|
||||||
|
|
||||||
|
self.reminders_webhook_url = QLineEdit(
|
||||||
|
self.current_settings.reminders_webhook_url or ""
|
||||||
|
)
|
||||||
|
self.reminders_webhook_secret = QLineEdit(
|
||||||
|
self.current_settings.reminders_webhook_secret or ""
|
||||||
|
)
|
||||||
|
self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
|
||||||
|
|
||||||
|
options_form.addRow(
|
||||||
|
strings._("reminders_webhook_url_label") + ":",
|
||||||
|
self.reminders_webhook_url,
|
||||||
|
)
|
||||||
|
options_form.addRow(
|
||||||
|
strings._("reminders_webhook_secret_label") + ":",
|
||||||
|
self.reminders_webhook_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
reminders_options_layout.addWidget(self.reminders_options_widget)
|
||||||
|
|
||||||
|
features_layout.addWidget(self.reminders_options_container)
|
||||||
|
|
||||||
|
self.reminders_options_container.setVisible(self.reminders.isChecked())
|
||||||
|
self.reminders_options_widget.setVisible(False)
|
||||||
|
|
||||||
self.documents = QCheckBox(strings._("enable_documents_feature"))
|
self.documents = QCheckBox(strings._("enable_documents_feature"))
|
||||||
self.documents.setChecked(self.current_settings.documents)
|
self.documents.setChecked(self.current_settings.documents)
|
||||||
self.documents.setCursor(Qt.PointingHandCursor)
|
self.documents.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.documents)
|
features_layout.addWidget(self.documents)
|
||||||
|
|
||||||
layout.addWidget(features_group)
|
layout.addWidget(features_group)
|
||||||
|
|
||||||
|
# --- Invoicing / company profile section -------------------------
|
||||||
|
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
|
||||||
|
invoicing_layout = QFormLayout(self.invoicing_group)
|
||||||
|
|
||||||
|
profile = self._db.get_company_profile() or (
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
|
||||||
|
|
||||||
|
self.company_name_edit = QLineEdit(name or "")
|
||||||
|
self.company_address_edit = QTextEdit(address or "")
|
||||||
|
self.company_phone_edit = QLineEdit(phone or "")
|
||||||
|
self.company_email_edit = QLineEdit(email or "")
|
||||||
|
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
||||||
|
self.company_payment_details_edit = QTextEdit()
|
||||||
|
self.company_payment_details_edit.setPlainText(payment_details or "")
|
||||||
|
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_name") + ":", self.company_name_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_address") + ":", self.company_address_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_phone") + ":", self.company_phone_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_email") + ":", self.company_email_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
||||||
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("invoice_company_payment_details") + ":",
|
||||||
|
self.company_payment_details_edit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logo picker - store bytes on self._logo_bytes
|
||||||
|
self._logo_bytes = logo_bytes
|
||||||
|
logo_row = QHBoxLayout()
|
||||||
|
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
|
||||||
|
if logo_bytes:
|
||||||
|
self.logo_label.setText(strings._("invoice_company_logo_set"))
|
||||||
|
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
|
||||||
|
logo_btn.clicked.connect(self._on_choose_logo)
|
||||||
|
logo_row.addWidget(self.logo_label)
|
||||||
|
logo_row.addWidget(logo_btn)
|
||||||
|
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
|
||||||
|
|
||||||
|
# Show/hide this whole block based on invoicing checkbox
|
||||||
|
self.invoicing_group.setVisible(self.invoicing.isChecked())
|
||||||
|
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
|
||||||
|
|
||||||
|
layout.addWidget(self.invoicing_group)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
@ -310,18 +460,92 @@ class SettingsDialog(QDialog):
|
||||||
idle_minutes=self.idle_spin.value(),
|
idle_minutes=self.idle_spin.value(),
|
||||||
theme=selected_theme.value,
|
theme=selected_theme.value,
|
||||||
move_todos=self.move_todos.isChecked(),
|
move_todos=self.move_todos.isChecked(),
|
||||||
|
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
|
||||||
tags=self.tags.isChecked(),
|
tags=self.tags.isChecked(),
|
||||||
time_log=self.time_log.isChecked(),
|
time_log=self.time_log.isChecked(),
|
||||||
reminders=self.reminders.isChecked(),
|
reminders=self.reminders.isChecked(),
|
||||||
|
reminders_webhook_url=self.reminders_webhook_url.text().strip() or None,
|
||||||
|
reminders_webhook_secret=self.reminders_webhook_secret.text().strip()
|
||||||
|
or None,
|
||||||
documents=self.documents.isChecked(),
|
documents=self.documents.isChecked(),
|
||||||
|
invoicing=(
|
||||||
|
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
||||||
|
),
|
||||||
locale=self.locale_combobox.currentText(),
|
locale=self.locale_combobox.currentText(),
|
||||||
font_size=self.font_size.value(),
|
font_size=self.font_size.value(),
|
||||||
)
|
)
|
||||||
|
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
|
|
||||||
|
# Save company profile only if invoicing is enabled
|
||||||
|
if self.invoicing.isChecked() and self.time_log.isChecked():
|
||||||
|
self._db.save_company_profile(
|
||||||
|
name=self.company_name_edit.text().strip() or None,
|
||||||
|
address=self.company_address_edit.toPlainText().strip() or None,
|
||||||
|
phone=self.company_phone_edit.text().strip() or None,
|
||||||
|
email=self.company_email_edit.text().strip() or None,
|
||||||
|
tax_id=self.company_tax_id_edit.text().strip() or None,
|
||||||
|
payment_details=self.company_payment_details_edit.toPlainText().strip()
|
||||||
|
or None,
|
||||||
|
logo=getattr(self, "_logo_bytes", None),
|
||||||
|
)
|
||||||
|
|
||||||
self.parent().themes.set(selected_theme)
|
self.parent().themes.set(selected_theme)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
def _on_reminders_options_toggled(self, checked: bool) -> None:
|
||||||
|
"""
|
||||||
|
Expand/collapse the advanced reminders options (webhook URL/secret).
|
||||||
|
"""
|
||||||
|
if checked:
|
||||||
|
self.reminders_options_toggle.setArrowType(Qt.DownArrow)
|
||||||
|
self.reminders_options_widget.show()
|
||||||
|
else:
|
||||||
|
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
|
||||||
|
self.reminders_options_widget.hide()
|
||||||
|
|
||||||
|
def _on_reminders_toggled(self, checked: bool) -> None:
|
||||||
|
"""
|
||||||
|
Conditionally show reminder webhook options depending
|
||||||
|
on if the reminders feature is toggled on or off.
|
||||||
|
"""
|
||||||
|
if hasattr(self, "reminders_options_container"):
|
||||||
|
self.reminders_options_container.setVisible(checked)
|
||||||
|
|
||||||
|
# When turning reminders off, also collapse the section
|
||||||
|
if not checked and hasattr(self, "reminders_options_toggle"):
|
||||||
|
self.reminders_options_toggle.setChecked(False)
|
||||||
|
self._on_reminders_options_toggled(False)
|
||||||
|
|
||||||
|
def _on_time_log_toggled(self, checked: bool) -> None:
|
||||||
|
"""
|
||||||
|
Enforce 'invoicing depends on time logging'.
|
||||||
|
"""
|
||||||
|
if not checked:
|
||||||
|
# Turn off + disable invoicing if time logging is disabled
|
||||||
|
self.invoicing.setChecked(False)
|
||||||
|
self.invoicing.setEnabled(False)
|
||||||
|
else:
|
||||||
|
# Let the user enable invoicing when time logging is enabled
|
||||||
|
self.invoicing.setEnabled(True)
|
||||||
|
|
||||||
|
def _on_choose_logo(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
strings._("company_logo_choose"),
|
||||||
|
"",
|
||||||
|
"Images (*.png *.jpg *.jpeg *.bmp)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
self._logo_bytes = f.read()
|
||||||
|
self.logo_label.setText(Path(path).name)
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, strings._("error"), str(exc))
|
||||||
|
|
||||||
def _change_key(self):
|
def _change_key(self):
|
||||||
p1 = KeyPrompt(
|
p1 = KeyPrompt(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,25 @@ from __future__ import annotations
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QSize, Signal
|
from PySide6.QtCore import QSize, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
|
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QLabel,
|
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QComboBox,
|
QLabel,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QWidget,
|
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .settings import load_db_config
|
from .settings import load_db_config
|
||||||
|
|
||||||
|
|
||||||
# ---------- Activity heatmap ----------
|
# ---------- Activity heatmap ----------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -216,7 +215,7 @@ class DateHeatmap(QWidget):
|
||||||
col = int((x - self._margin_left) // cell_span) # week index
|
col = int((x - self._margin_left) // cell_span) # week index
|
||||||
row = int((y - self._margin_top) // cell_span) # dow (0..6)
|
row = int((y - self._margin_top) // cell_span) # dow (0..6)
|
||||||
|
|
||||||
# Only 7 rows (Mon–Sun)
|
# Only 7 rows (Mon-Sun)
|
||||||
if not (0 <= row < 7):
|
if not (0 <= row < 7):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -249,8 +248,9 @@ class StatisticsDialog(QDialog):
|
||||||
self._db = db
|
self._db = db
|
||||||
|
|
||||||
self.setWindowTitle(strings._("statistics"))
|
self.setWindowTitle(strings._("statistics"))
|
||||||
self.setMinimumWidth(600)
|
self.setMinimumWidth(650)
|
||||||
self.setMinimumHeight(400)
|
self.setMinimumHeight(650)
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
(
|
(
|
||||||
|
|
@ -264,12 +264,23 @@ class StatisticsDialog(QDialog):
|
||||||
page_most_tags,
|
page_most_tags,
|
||||||
page_most_tags_count,
|
page_most_tags_count,
|
||||||
revisions_by_date,
|
revisions_by_date,
|
||||||
|
time_minutes_by_date,
|
||||||
|
total_time_minutes,
|
||||||
|
day_most_time,
|
||||||
|
day_most_time_minutes,
|
||||||
|
project_most_minutes_name,
|
||||||
|
project_most_minutes,
|
||||||
|
activity_most_minutes_name,
|
||||||
|
activity_most_minutes,
|
||||||
|
reminders_by_date,
|
||||||
|
total_reminders,
|
||||||
|
day_most_reminders,
|
||||||
|
day_most_reminders_count,
|
||||||
) = self._gather_stats()
|
) = self._gather_stats()
|
||||||
|
|
||||||
# Optional: per-date document counts for the heatmap.
|
|
||||||
# This uses project_documents.uploaded_at aggregated by day, if the
|
|
||||||
# Documents feature is enabled.
|
|
||||||
self.cfg = load_db_config()
|
self.cfg = load_db_config()
|
||||||
|
|
||||||
|
# Optional: per-date document counts for the heatmap.
|
||||||
documents_by_date: Dict[_dt.date, int] = {}
|
documents_by_date: Dict[_dt.date, int] = {}
|
||||||
total_documents = 0
|
total_documents = 0
|
||||||
date_most_documents: _dt.date | None = None
|
date_most_documents: _dt.date | None = None
|
||||||
|
|
@ -281,76 +292,184 @@ class StatisticsDialog(QDialog):
|
||||||
except Exception:
|
except Exception:
|
||||||
documents_by_date = {}
|
documents_by_date = {}
|
||||||
|
|
||||||
if documents_by_date:
|
if documents_by_date:
|
||||||
total_documents = sum(documents_by_date.values())
|
total_documents = sum(documents_by_date.values())
|
||||||
# Choose the date with the highest count, tie-breaking by earliest date.
|
# Choose the date with the highest count, tie-breaking by earliest date.
|
||||||
date_most_documents, date_most_documents_count = sorted(
|
date_most_documents, date_most_documents_count = sorted(
|
||||||
documents_by_date.items(),
|
documents_by_date.items(),
|
||||||
key=lambda item: (-item[1], item[0]),
|
key=lambda item: (-item[1], item[0]),
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# for the heatmap
|
# For the heatmap
|
||||||
self._documents_by_date = documents_by_date
|
self._documents_by_date = documents_by_date
|
||||||
|
self._time_by_date = time_minutes_by_date
|
||||||
|
self._reminders_by_date = reminders_by_date
|
||||||
|
self._words_by_date = words_by_date
|
||||||
|
self._revisions_by_date = revisions_by_date
|
||||||
|
|
||||||
# --- Numeric summary at the top ----------------------------------
|
# ------------------------------------------------------------------
|
||||||
form = QFormLayout()
|
# Feature groups
|
||||||
root.addLayout(form)
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
form.addRow(
|
# --- Pages / words / revisions -----------------------------------
|
||||||
|
pages_group = QGroupBox(strings._("stats_group_pages"))
|
||||||
|
pages_form = QFormLayout(pages_group)
|
||||||
|
|
||||||
|
pages_form.addRow(
|
||||||
strings._("stats_pages_with_content"),
|
strings._("stats_pages_with_content"),
|
||||||
QLabel(str(pages_with_content)),
|
QLabel(str(pages_with_content)),
|
||||||
)
|
)
|
||||||
form.addRow(
|
pages_form.addRow(
|
||||||
strings._("stats_total_revisions"),
|
strings._("stats_total_revisions"),
|
||||||
QLabel(str(total_revisions)),
|
QLabel(str(total_revisions)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if page_most_revisions:
|
if page_most_revisions:
|
||||||
form.addRow(
|
pages_form.addRow(
|
||||||
strings._("stats_page_most_revisions"),
|
strings._("stats_page_most_revisions"),
|
||||||
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
|
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form.addRow(strings._("stats_page_most_revisions"), QLabel("—"))
|
pages_form.addRow(
|
||||||
|
strings._("stats_page_most_revisions"),
|
||||||
|
QLabel("—"),
|
||||||
|
)
|
||||||
|
|
||||||
form.addRow(
|
pages_form.addRow(
|
||||||
strings._("stats_total_words"),
|
strings._("stats_total_words"),
|
||||||
QLabel(str(total_words)),
|
QLabel(str(total_words)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tags
|
root.addWidget(pages_group)
|
||||||
|
|
||||||
|
# --- Tags ---------------------------------------------------------
|
||||||
if self.cfg.tags:
|
if self.cfg.tags:
|
||||||
form.addRow(
|
tags_group = QGroupBox(strings._("stats_group_tags"))
|
||||||
|
tags_form = QFormLayout(tags_group)
|
||||||
|
|
||||||
|
tags_form.addRow(
|
||||||
strings._("stats_unique_tags"),
|
strings._("stats_unique_tags"),
|
||||||
QLabel(str(unique_tags)),
|
QLabel(str(unique_tags)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if page_most_tags:
|
if page_most_tags:
|
||||||
form.addRow(
|
tags_form.addRow(
|
||||||
strings._("stats_page_most_tags"),
|
strings._("stats_page_most_tags"),
|
||||||
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form.addRow(strings._("stats_page_most_tags"), QLabel("—"))
|
tags_form.addRow(
|
||||||
|
strings._("stats_page_most_tags"),
|
||||||
|
QLabel("—"),
|
||||||
|
)
|
||||||
|
|
||||||
# Documents
|
root.addWidget(tags_group)
|
||||||
if date_most_documents:
|
|
||||||
form.addRow(
|
# --- Documents ----------------------------------------------------
|
||||||
|
if self.cfg.documents:
|
||||||
|
docs_group = QGroupBox(strings._("stats_group_documents"))
|
||||||
|
docs_form = QFormLayout(docs_group)
|
||||||
|
|
||||||
|
docs_form.addRow(
|
||||||
strings._("stats_total_documents"),
|
strings._("stats_total_documents"),
|
||||||
QLabel(str(total_documents)),
|
QLabel(str(total_documents)),
|
||||||
)
|
)
|
||||||
|
|
||||||
doc_most_label = (
|
if date_most_documents:
|
||||||
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
|
doc_most_label = (
|
||||||
)
|
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
doc_most_label = "—"
|
||||||
|
|
||||||
form.addRow(
|
docs_form.addRow(
|
||||||
strings._("stats_date_most_documents"),
|
strings._("stats_date_most_documents"),
|
||||||
QLabel(doc_most_label),
|
QLabel(doc_most_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Heatmap with switcher ---------------------------------------
|
root.addWidget(docs_group)
|
||||||
if words_by_date or revisions_by_date or documents_by_date:
|
|
||||||
|
# --- Time logging -------------------------------------------------
|
||||||
|
if self.cfg.time_log:
|
||||||
|
time_group = QGroupBox(strings._("stats_group_time_logging"))
|
||||||
|
time_form = QFormLayout(time_group)
|
||||||
|
|
||||||
|
total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0
|
||||||
|
time_form.addRow(
|
||||||
|
strings._("stats_time_total_hours"),
|
||||||
|
QLabel(f"{total_hours:.2f}h"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if day_most_time:
|
||||||
|
day_hours = (
|
||||||
|
day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0
|
||||||
|
)
|
||||||
|
day_label = f"{day_most_time} ({day_hours:.2f}h)"
|
||||||
|
else:
|
||||||
|
day_label = "—"
|
||||||
|
time_form.addRow(
|
||||||
|
strings._("stats_time_day_most_hours"),
|
||||||
|
QLabel(day_label),
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_most_minutes_name:
|
||||||
|
proj_hours = (
|
||||||
|
project_most_minutes / 60.0 if project_most_minutes else 0.0
|
||||||
|
)
|
||||||
|
proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)"
|
||||||
|
else:
|
||||||
|
proj_label = "—"
|
||||||
|
time_form.addRow(
|
||||||
|
strings._("stats_time_project_most_hours"),
|
||||||
|
QLabel(proj_label),
|
||||||
|
)
|
||||||
|
|
||||||
|
if activity_most_minutes_name:
|
||||||
|
act_hours = (
|
||||||
|
activity_most_minutes / 60.0 if activity_most_minutes else 0.0
|
||||||
|
)
|
||||||
|
act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)"
|
||||||
|
else:
|
||||||
|
act_label = "—"
|
||||||
|
time_form.addRow(
|
||||||
|
strings._("stats_time_activity_most_hours"),
|
||||||
|
QLabel(act_label),
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(time_group)
|
||||||
|
|
||||||
|
# --- Reminders ----------------------------------------------------
|
||||||
|
if self.cfg.reminders:
|
||||||
|
rem_group = QGroupBox(strings._("stats_group_reminders"))
|
||||||
|
rem_form = QFormLayout(rem_group)
|
||||||
|
|
||||||
|
rem_form.addRow(
|
||||||
|
strings._("stats_total_reminders"),
|
||||||
|
QLabel(str(total_reminders)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if day_most_reminders:
|
||||||
|
rem_label = f"{day_most_reminders} ({day_most_reminders_count})"
|
||||||
|
else:
|
||||||
|
rem_label = "—"
|
||||||
|
|
||||||
|
rem_form.addRow(
|
||||||
|
strings._("stats_date_most_reminders"),
|
||||||
|
QLabel(rem_label),
|
||||||
|
)
|
||||||
|
|
||||||
|
root.addWidget(rem_group)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Heatmap with metric switcher
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if (
|
||||||
|
words_by_date
|
||||||
|
or revisions_by_date
|
||||||
|
or documents_by_date
|
||||||
|
or time_minutes_by_date
|
||||||
|
or reminders_by_date
|
||||||
|
):
|
||||||
group = QGroupBox(strings._("stats_activity_heatmap"))
|
group = QGroupBox(strings._("stats_activity_heatmap"))
|
||||||
group_layout = QVBoxLayout(group)
|
group_layout = QVBoxLayout(group)
|
||||||
|
|
||||||
|
|
@ -359,18 +478,30 @@ class StatisticsDialog(QDialog):
|
||||||
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
|
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
|
||||||
self.metric_combo = QComboBox()
|
self.metric_combo = QComboBox()
|
||||||
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
|
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
|
||||||
self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
|
self.metric_combo.addItem(
|
||||||
|
strings._("stats_metric_revisions"),
|
||||||
|
"revisions",
|
||||||
|
)
|
||||||
if documents_by_date:
|
if documents_by_date:
|
||||||
self.metric_combo.addItem(
|
self.metric_combo.addItem(
|
||||||
strings._("stats_metric_documents"), "documents"
|
strings._("stats_metric_documents"),
|
||||||
|
"documents",
|
||||||
|
)
|
||||||
|
if self.cfg.time_log and time_minutes_by_date:
|
||||||
|
self.metric_combo.addItem(
|
||||||
|
strings._("stats_metric_hours"),
|
||||||
|
"hours",
|
||||||
|
)
|
||||||
|
if self.cfg.reminders and reminders_by_date:
|
||||||
|
self.metric_combo.addItem(
|
||||||
|
strings._("stats_metric_reminders"),
|
||||||
|
"reminders",
|
||||||
)
|
)
|
||||||
combo_row.addWidget(self.metric_combo)
|
combo_row.addWidget(self.metric_combo)
|
||||||
combo_row.addStretch(1)
|
combo_row.addStretch(1)
|
||||||
group_layout.addLayout(combo_row)
|
group_layout.addLayout(combo_row)
|
||||||
|
|
||||||
self._heatmap = DateHeatmap()
|
self._heatmap = DateHeatmap()
|
||||||
self._words_by_date = words_by_date
|
|
||||||
self._revisions_by_date = revisions_by_date
|
|
||||||
|
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
scroll.setWidgetResizable(True)
|
scroll.setWidgetResizable(True)
|
||||||
|
|
@ -387,6 +518,8 @@ class StatisticsDialog(QDialog):
|
||||||
else:
|
else:
|
||||||
root.addWidget(QLabel(strings._("stats_no_data")))
|
root.addWidget(QLabel(strings._("stats_no_data")))
|
||||||
|
|
||||||
|
self.resize(self.sizeHint().width(), self.sizeHint().height())
|
||||||
|
|
||||||
# ---------- internal helpers ----------
|
# ---------- internal helpers ----------
|
||||||
|
|
||||||
def _apply_metric(self, metric: str) -> None:
|
def _apply_metric(self, metric: str) -> None:
|
||||||
|
|
@ -394,6 +527,10 @@ class StatisticsDialog(QDialog):
|
||||||
self._heatmap.set_data(self._revisions_by_date)
|
self._heatmap.set_data(self._revisions_by_date)
|
||||||
elif metric == "documents":
|
elif metric == "documents":
|
||||||
self._heatmap.set_data(self._documents_by_date)
|
self._heatmap.set_data(self._documents_by_date)
|
||||||
|
elif metric == "hours":
|
||||||
|
self._heatmap.set_data(self._time_by_date)
|
||||||
|
elif metric == "reminders":
|
||||||
|
self._heatmap.set_data(self._reminders_by_date)
|
||||||
else:
|
else:
|
||||||
self._heatmap.set_data(self._words_by_date)
|
self._heatmap.set_data(self._words_by_date)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from importlib.resources import files
|
|
||||||
import json
|
import json
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
# Get list of locales
|
# Get list of locales
|
||||||
root = files("bouquin") / "locales"
|
root = files("bouquin") / "locales"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QColor
|
from PySide6.QtGui import QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QColorDialog,
|
||||||
QDialog,
|
QDialog,
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
QTreeWidget,
|
QTreeWidget,
|
||||||
QTreeWidgetItem,
|
QTreeWidgetItem,
|
||||||
QPushButton,
|
QVBoxLayout,
|
||||||
QLabel,
|
|
||||||
QColorDialog,
|
|
||||||
QMessageBox,
|
|
||||||
QInputDialog,
|
|
||||||
)
|
)
|
||||||
|
from sqlcipher3.dbapi2 import IntegrityError
|
||||||
|
|
||||||
|
from . import strings
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .settings import load_db_config
|
from .settings import load_db_config
|
||||||
from . import strings
|
|
||||||
from sqlcipher3.dbapi2 import IntegrityError
|
|
||||||
|
|
||||||
|
|
||||||
class TagBrowserDialog(QDialog):
|
class TagBrowserDialog(QDialog):
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QCompleter,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
QToolButton,
|
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QStyle,
|
QStyle,
|
||||||
QCompleter,
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from 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 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):
|
class Theme(Enum):
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,49 @@ from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import html
|
import html
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlcipher3.dbapi2 import IntegrityError
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QDate, QUrl
|
from PySide6.QtCore import QDate, Qt, QUrl, Signal
|
||||||
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
|
from PySide6.QtGui import QColor, QImage, QPageLayout, QPainter, QTextDocument
|
||||||
from PySide6.QtPrintSupport import QPrinter
|
from PySide6.QtPrintSupport import QPrinter
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
|
QComboBox,
|
||||||
|
QCompleter,
|
||||||
|
QDateEdit,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFrame,
|
QDoubleSpinBox,
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QLabel,
|
QFrame,
|
||||||
QComboBox,
|
QHBoxLayout,
|
||||||
QLineEdit,
|
|
||||||
QDoubleSpinBox,
|
|
||||||
QPushButton,
|
|
||||||
QTableWidget,
|
|
||||||
QTableWidgetItem,
|
|
||||||
QAbstractItemView,
|
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
QTabWidget,
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QDateEdit,
|
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QCompleter,
|
QPushButton,
|
||||||
QToolButton,
|
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QStyle,
|
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 . import strings
|
||||||
|
from .db import DBManager
|
||||||
|
from .settings import load_db_config
|
||||||
|
from .theme import ThemeManager
|
||||||
|
|
||||||
|
|
||||||
class TimeLogWidget(QFrame):
|
class TimeLogWidget(QFrame):
|
||||||
|
|
@ -53,6 +53,8 @@ class TimeLogWidget(QFrame):
|
||||||
Shown in the left sidebar above the Tags widget.
|
Shown in the left sidebar above the Tags widget.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
remindersChanged = Signal()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db: DBManager,
|
db: DBManager,
|
||||||
|
|
@ -61,6 +63,7 @@ class TimeLogWidget(QFrame):
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.cfg = load_db_config()
|
||||||
self._themes = themes
|
self._themes = themes
|
||||||
self._current_date: Optional[str] = None
|
self._current_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -82,6 +85,15 @@ class TimeLogWidget(QFrame):
|
||||||
self.log_btn.setAutoRaise(True)
|
self.log_btn.setAutoRaise(True)
|
||||||
self.log_btn.clicked.connect(self._open_dialog_log_only)
|
self.log_btn.clicked.connect(self._open_dialog_log_only)
|
||||||
|
|
||||||
|
self.report_btn = QToolButton()
|
||||||
|
self.report_btn.setText("📈")
|
||||||
|
self.report_btn.setAutoRaise(True)
|
||||||
|
self.report_btn.clicked.connect(self._on_run_report)
|
||||||
|
if self.cfg.invoicing:
|
||||||
|
self.report_btn.setToolTip(strings._("reporting_and_invoicing"))
|
||||||
|
else:
|
||||||
|
self.report_btn.setToolTip(strings._("reporting"))
|
||||||
|
|
||||||
self.open_btn = QToolButton()
|
self.open_btn = QToolButton()
|
||||||
self.open_btn.setIcon(
|
self.open_btn.setIcon(
|
||||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||||
|
|
@ -95,6 +107,7 @@ class TimeLogWidget(QFrame):
|
||||||
header.addWidget(self.toggle_btn)
|
header.addWidget(self.toggle_btn)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
header.addWidget(self.log_btn)
|
header.addWidget(self.log_btn)
|
||||||
|
header.addWidget(self.report_btn)
|
||||||
header.addWidget(self.open_btn)
|
header.addWidget(self.open_btn)
|
||||||
|
|
||||||
# Body: simple summary label for the day
|
# Body: simple summary label for the day
|
||||||
|
|
@ -149,6 +162,14 @@ class TimeLogWidget(QFrame):
|
||||||
|
|
||||||
# ----- internals ---------------------------------------------------
|
# ----- internals ---------------------------------------------------
|
||||||
|
|
||||||
|
def _on_run_report(self) -> None:
|
||||||
|
dlg = TimeReportDialog(self._db, self)
|
||||||
|
|
||||||
|
# Bubble the remindersChanged signal further up
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
def _on_toggle(self, checked: bool) -> None:
|
def _on_toggle(self, checked: bool) -> None:
|
||||||
self.body.setVisible(checked)
|
self.body.setVisible(checked)
|
||||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||||
|
|
@ -247,7 +268,8 @@ class TimeLogDialog(QDialog):
|
||||||
self._themes = themes
|
self._themes = themes
|
||||||
self._date_iso = date_iso
|
self._date_iso = date_iso
|
||||||
self._current_entry_id: Optional[int] = None
|
self._current_entry_id: Optional[int] = None
|
||||||
# Guard flag used when repopulating the table so we don’t treat
|
self.cfg = load_db_config()
|
||||||
|
# Guard flag used when repopulating the table so we don't treat
|
||||||
# programmatic item changes as user edits.
|
# programmatic item changes as user edits.
|
||||||
self._reloading_entries: bool = False
|
self._reloading_entries: bool = False
|
||||||
|
|
||||||
|
|
@ -255,7 +277,7 @@ class TimeLogDialog(QDialog):
|
||||||
|
|
||||||
self.close_after_add = close_after_add
|
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)
|
self.resize(900, 600)
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
@ -263,12 +285,12 @@ class TimeLogDialog(QDialog):
|
||||||
# --- Top: date label + change-date button
|
# --- Top: date label + change-date button
|
||||||
date_row = QHBoxLayout()
|
date_row = QHBoxLayout()
|
||||||
|
|
||||||
self.date_label = QLabel(strings._("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.addWidget(self.date_label)
|
||||||
|
|
||||||
date_row.addStretch(1)
|
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)
|
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
|
||||||
date_row.addWidget(self.change_date_btn)
|
date_row.addWidget(self.change_date_btn)
|
||||||
|
|
||||||
|
|
@ -320,13 +342,9 @@ class TimeLogDialog(QDialog):
|
||||||
self.delete_btn.clicked.connect(self._on_delete_entry)
|
self.delete_btn.clicked.connect(self._on_delete_entry)
|
||||||
self.delete_btn.setEnabled(False)
|
self.delete_btn.setEnabled(False)
|
||||||
|
|
||||||
self.report_btn = QPushButton("&" + strings._("run_report"))
|
|
||||||
self.report_btn.clicked.connect(self._on_run_report)
|
|
||||||
|
|
||||||
btn_row.addStretch(1)
|
btn_row.addStretch(1)
|
||||||
btn_row.addWidget(self.add_update_btn)
|
btn_row.addWidget(self.add_update_btn)
|
||||||
btn_row.addWidget(self.delete_btn)
|
btn_row.addWidget(self.delete_btn)
|
||||||
btn_row.addWidget(self.report_btn)
|
|
||||||
root.addLayout(btn_row)
|
root.addLayout(btn_row)
|
||||||
|
|
||||||
# --- Table of entries for this date
|
# --- Table of entries for this date
|
||||||
|
|
@ -355,12 +373,19 @@ class TimeLogDialog(QDialog):
|
||||||
self.table.itemChanged.connect(self._on_table_item_changed)
|
self.table.itemChanged.connect(self._on_table_item_changed)
|
||||||
root.addWidget(self.table, 1)
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
# --- Total time and Close button
|
# --- Total time, Reporting and Close button
|
||||||
close_row = QHBoxLayout()
|
close_row = QHBoxLayout()
|
||||||
self.total_label = QLabel(
|
self.total_label = QLabel(
|
||||||
strings._("time_log_total_hours").format(hours=self.total_hours)
|
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.total_label)
|
||||||
|
close_row.addWidget(self.report_btn)
|
||||||
close_row.addStretch(1)
|
close_row.addStretch(1)
|
||||||
close_btn = QPushButton(strings._("close"))
|
close_btn = QPushButton(strings._("close"))
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
|
|
@ -452,7 +477,7 @@ class TimeLogDialog(QDialog):
|
||||||
current_qdate = QDate.currentDate()
|
current_qdate = QDate.currentDate()
|
||||||
|
|
||||||
dlg = QDialog(self)
|
dlg = QDialog(self)
|
||||||
dlg.setWindowTitle(strings._("time_log_select_date_title"))
|
dlg.setWindowTitle(strings._("select_date_title"))
|
||||||
|
|
||||||
layout = QVBoxLayout(dlg)
|
layout = QVBoxLayout(dlg)
|
||||||
|
|
||||||
|
|
@ -483,8 +508,8 @@ class TimeLogDialog(QDialog):
|
||||||
self._date_iso = new_iso
|
self._date_iso = new_iso
|
||||||
|
|
||||||
# Update window title and header label
|
# Update window title and header label
|
||||||
self.setWindowTitle(strings._("time_log_for").format(date=new_iso))
|
self.setWindowTitle(strings._("for").format(date=new_iso))
|
||||||
self.date_label.setText(strings._("time_log_date_label").format(date=new_iso))
|
self.date_label.setText(strings._("date_label").format(date=new_iso))
|
||||||
|
|
||||||
# Reload entries for the newly selected date
|
# Reload entries for the newly selected date
|
||||||
self._reload_entries()
|
self._reload_entries()
|
||||||
|
|
@ -594,7 +619,7 @@ class TimeLogDialog(QDialog):
|
||||||
hours_item = self.table.item(row, 3)
|
hours_item = self.table.item(row, 3)
|
||||||
|
|
||||||
if proj_item is None or act_item is None or hours_item is None:
|
if proj_item is None or act_item is None or hours_item is None:
|
||||||
# Incomplete row – nothing to do.
|
# Incomplete row - nothing to do.
|
||||||
return
|
return
|
||||||
|
|
||||||
# Recover the entry id from the hidden UserRole on the project cell
|
# Recover the entry id from the hidden UserRole on the project cell
|
||||||
|
|
@ -803,7 +828,7 @@ class TimeCodeManagerDialog(QDialog):
|
||||||
try:
|
try:
|
||||||
self._db.add_project(name)
|
self._db.add_project(name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Empty / invalid name – nothing to do, but be defensive
|
# Empty / invalid name - nothing to do, but be defensive
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
strings._("invalid_project_title"),
|
strings._("invalid_project_title"),
|
||||||
|
|
@ -981,17 +1006,21 @@ class TimeReportDialog(QDialog):
|
||||||
Shows decimal hours per time period.
|
Shows decimal hours per time period.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
remindersChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, db: DBManager, parent=None):
|
def __init__(self, db: DBManager, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
|
||||||
# state for last run
|
# state for last run
|
||||||
self._last_rows: list[tuple[str, str, int]] = []
|
self._last_rows: list[tuple[str, str, str, str, int]] = []
|
||||||
self._last_total_minutes: int = 0
|
self._last_total_minutes: int = 0
|
||||||
self._last_project_name: str = ""
|
self._last_project_name: str = ""
|
||||||
self._last_start: str = ""
|
self._last_start: str = ""
|
||||||
self._last_end: str = ""
|
self._last_end: str = ""
|
||||||
self._last_gran_label: str = ""
|
self._last_gran_label: str = ""
|
||||||
|
self._last_time_logs: list = []
|
||||||
|
|
||||||
self.setWindowTitle(strings._("time_log_report"))
|
self.setWindowTitle(strings._("time_log_report"))
|
||||||
self.resize(600, 400)
|
self.resize(600, 400)
|
||||||
|
|
@ -999,9 +1028,20 @@ class TimeReportDialog(QDialog):
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.invoice_btn = QPushButton(strings._("create_invoice"))
|
||||||
|
self.invoice_btn.clicked.connect(self._on_create_invoice)
|
||||||
|
|
||||||
|
self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
|
||||||
|
self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
self.project_combo = QComboBox()
|
self.project_combo = QComboBox()
|
||||||
self.project_combo.addItem(strings._("all_projects"), None)
|
self.project_combo.addItem(strings._("all_projects"), None)
|
||||||
|
self.project_combo.currentIndexChanged.connect(
|
||||||
|
self._update_invoice_button_state
|
||||||
|
)
|
||||||
|
self._update_invoice_button_state()
|
||||||
for proj_id, name in self._db.list_projects():
|
for proj_id, name in self._db.list_projects():
|
||||||
self.project_combo.addItem(name, proj_id)
|
self.project_combo.addItem(name, proj_id)
|
||||||
form.addRow(strings._("project"), self.project_combo)
|
form.addRow(strings._("project"), self.project_combo)
|
||||||
|
|
@ -1013,6 +1053,7 @@ class TimeReportDialog(QDialog):
|
||||||
self.range_preset = QComboBox()
|
self.range_preset = QComboBox()
|
||||||
self.range_preset.addItem(strings._("custom_range"), "custom")
|
self.range_preset.addItem(strings._("custom_range"), "custom")
|
||||||
self.range_preset.addItem(strings._("today"), "today")
|
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_week"), "this_week")
|
||||||
self.range_preset.addItem(strings._("this_month"), "this_month")
|
self.range_preset.addItem(strings._("this_month"), "this_month")
|
||||||
self.range_preset.addItem(strings._("this_year"), "this_year")
|
self.range_preset.addItem(strings._("this_year"), "this_year")
|
||||||
|
|
@ -1038,9 +1079,11 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# Granularity
|
# Granularity
|
||||||
self.granularity = QComboBox()
|
self.granularity = QComboBox()
|
||||||
|
self.granularity.addItem(strings._("dont_group"), "none")
|
||||||
self.granularity.addItem(strings._("by_day"), "day")
|
self.granularity.addItem(strings._("by_day"), "day")
|
||||||
self.granularity.addItem(strings._("by_week"), "week")
|
self.granularity.addItem(strings._("by_week"), "week")
|
||||||
self.granularity.addItem(strings._("by_month"), "month")
|
self.granularity.addItem(strings._("by_month"), "month")
|
||||||
|
self.granularity.addItem(strings._("by_activity"), "activity")
|
||||||
form.addRow(strings._("group_by"), self.granularity)
|
form.addRow(strings._("group_by"), self.granularity)
|
||||||
|
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
@ -1060,6 +1103,10 @@ class TimeReportDialog(QDialog):
|
||||||
run_row.addWidget(run_btn)
|
run_row.addWidget(run_btn)
|
||||||
run_row.addWidget(export_btn)
|
run_row.addWidget(export_btn)
|
||||||
run_row.addWidget(pdf_btn)
|
run_row.addWidget(pdf_btn)
|
||||||
|
# Only show invoicing if the feature is enabled
|
||||||
|
if getattr(self._db.cfg, "invoicing", False):
|
||||||
|
run_row.addWidget(self.invoice_btn)
|
||||||
|
run_row.addWidget(self.manage_invoices_btn)
|
||||||
root.addLayout(run_row)
|
root.addLayout(run_row)
|
||||||
|
|
||||||
# Table
|
# Table
|
||||||
|
|
@ -1095,6 +1142,57 @@ class TimeReportDialog(QDialog):
|
||||||
close_row.addWidget(close_btn)
|
close_row.addWidget(close_btn)
|
||||||
root.addLayout(close_row)
|
root.addLayout(close_row)
|
||||||
|
|
||||||
|
def _configure_table_columns(self, granularity: str) -> None:
|
||||||
|
if granularity == "none":
|
||||||
|
# Show notes
|
||||||
|
self.table.setColumnCount(5)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("project"),
|
||||||
|
strings._("time_period"),
|
||||||
|
strings._("activity"),
|
||||||
|
strings._("note"),
|
||||||
|
strings._("hours"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# project, period, activity, note stretch; hours shrink
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||||
|
elif granularity == "activity":
|
||||||
|
# Grouped by activity only: no time period, no note column
|
||||||
|
self.table.setColumnCount(3)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("project"),
|
||||||
|
strings._("activity"),
|
||||||
|
strings._("hours"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
|
else:
|
||||||
|
# Grouped: no note column
|
||||||
|
self.table.setColumnCount(4)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("project"),
|
||||||
|
strings._("time_period"),
|
||||||
|
strings._("activity"),
|
||||||
|
strings._("hours"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
|
|
||||||
def _on_range_preset_changed(self, index: int) -> None:
|
def _on_range_preset_changed(self, index: int) -> None:
|
||||||
preset = self.range_preset.currentData()
|
preset = self.range_preset.currentData()
|
||||||
today = QDate.currentDate()
|
today = QDate.currentDate()
|
||||||
|
|
@ -1108,6 +1206,14 @@ class TimeReportDialog(QDialog):
|
||||||
start = today.addDays(1 - today.dayOfWeek())
|
start = today.addDays(1 - today.dayOfWeek())
|
||||||
end = today
|
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":
|
elif preset == "this_month":
|
||||||
start = QDate(today.year(), today.month(), 1)
|
start = QDate(today.year(), today.month(), 1)
|
||||||
end = today
|
end = today
|
||||||
|
|
@ -1116,7 +1222,7 @@ class TimeReportDialog(QDialog):
|
||||||
start = QDate(today.year(), 1, 1)
|
start = QDate(today.year(), 1, 1)
|
||||||
end = today
|
end = today
|
||||||
|
|
||||||
else: # "custom" – leave fields as user-set
|
else: # "custom" - leave fields as user-set
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update date edits without triggering anything else
|
# Update date edits without triggering anything else
|
||||||
|
|
@ -1140,17 +1246,22 @@ class TimeReportDialog(QDialog):
|
||||||
self._last_start = start
|
self._last_start = start
|
||||||
self._last_end = end
|
self._last_end = end
|
||||||
self._last_gran_label = self.granularity.currentText()
|
self._last_gran_label = self.granularity.currentText()
|
||||||
|
self._last_gran = gran # remember which grouping was used
|
||||||
|
|
||||||
|
self._configure_table_columns(gran)
|
||||||
|
|
||||||
rows_for_table: list[tuple[str, str, str, str, int]] = []
|
rows_for_table: list[tuple[str, str, str, str, int]] = []
|
||||||
|
|
||||||
if proj_data is None:
|
if proj_data is None:
|
||||||
# All projects
|
# All projects
|
||||||
self._last_all_projects = True
|
self._last_all_projects = True
|
||||||
|
self._last_time_logs = []
|
||||||
self._last_project_name = strings._("all_projects")
|
self._last_project_name = strings._("all_projects")
|
||||||
rows_for_table = self._db.time_report_all(start, end, gran)
|
rows_for_table = self._db.time_report_all(start, end, gran)
|
||||||
else:
|
else:
|
||||||
self._last_all_projects = False
|
self._last_all_projects = False
|
||||||
proj_id = int(proj_data)
|
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()
|
project_name = self.project_combo.currentText()
|
||||||
self._last_project_name = project_name
|
self._last_project_name = project_name
|
||||||
|
|
||||||
|
|
@ -1176,13 +1287,23 @@ class TimeReportDialog(QDialog):
|
||||||
rows_for_table
|
rows_for_table
|
||||||
):
|
):
|
||||||
hrs = minutes / 60.0
|
hrs = minutes / 60.0
|
||||||
self.table.setItem(i, 0, QTableWidgetItem(project))
|
if self._last_gran == "activity":
|
||||||
self.table.setItem(i, 1, QTableWidgetItem(time_period))
|
self.table.setItem(i, 0, QTableWidgetItem(project))
|
||||||
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
|
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
|
||||||
self.table.setItem(i, 3, QTableWidgetItem(note))
|
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
|
else:
|
||||||
|
self.table.setItem(i, 0, QTableWidgetItem(project))
|
||||||
|
self.table.setItem(i, 1, QTableWidgetItem(time_period))
|
||||||
|
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
|
||||||
|
|
||||||
# Summary label – include per-project totals when in "all projects" mode
|
if self._last_gran == "none":
|
||||||
|
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
|
||||||
|
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
|
else:
|
||||||
|
# no note column
|
||||||
|
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
|
|
||||||
|
# Summary label - include per-project totals when in "all projects" mode
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
if self._last_all_projects:
|
if self._last_all_projects:
|
||||||
per_project_bits = [
|
per_project_bits = [
|
||||||
|
|
@ -1224,16 +1345,19 @@ class TimeReportDialog(QDialog):
|
||||||
with open(filename, "w", newline="", encoding="utf-8") as f:
|
with open(filename, "w", newline="", encoding="utf-8") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
|
|
||||||
|
gran = getattr(self, "_last_gran", "day")
|
||||||
|
show_note = gran == "none"
|
||||||
|
show_period = gran != "activity"
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
writer.writerow(
|
header: list[str] = [strings._("project")]
|
||||||
[
|
if show_period:
|
||||||
strings._("project"),
|
header.append(strings._("time_period"))
|
||||||
strings._("time_period"),
|
header.append(strings._("activity"))
|
||||||
strings._("activity"),
|
if show_note:
|
||||||
strings._("note"),
|
header.append(strings._("note"))
|
||||||
strings._("hours"),
|
header.append(strings._("hours"))
|
||||||
]
|
writer.writerow(header)
|
||||||
)
|
|
||||||
|
|
||||||
# Data rows
|
# Data rows
|
||||||
for (
|
for (
|
||||||
|
|
@ -1244,14 +1368,22 @@ class TimeReportDialog(QDialog):
|
||||||
minutes,
|
minutes,
|
||||||
) in self._last_rows:
|
) in self._last_rows:
|
||||||
hours = minutes / 60.0
|
hours = minutes / 60.0
|
||||||
writer.writerow(
|
row: list[str] = [project]
|
||||||
[project, time_period, activity_name, note, f"{hours:.2f}"]
|
if show_period:
|
||||||
)
|
row.append(time_period)
|
||||||
|
row.append(activity_name)
|
||||||
|
if show_note:
|
||||||
|
row.append(note or "")
|
||||||
|
row.append(f"{hours:.2f}")
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
# Blank line + total
|
# Blank line + total
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
|
total_row = [""] * len(header)
|
||||||
|
total_row[0] = strings._("total")
|
||||||
|
total_row[-1] = f"{total_hours:.2f}"
|
||||||
|
writer.writerow(total_row)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1279,17 +1411,20 @@ class TimeReportDialog(QDialog):
|
||||||
if not filename.endswith(".pdf"):
|
if not filename.endswith(".pdf"):
|
||||||
filename = f"{filename}.pdf"
|
filename = f"{filename}.pdf"
|
||||||
|
|
||||||
# ---------- Build chart image (hours per period) ----------
|
# ---------- Build chart image ----------
|
||||||
per_period_minutes: dict[str, int] = defaultdict(int)
|
# Default: hours per time period. If grouped by activity: hours per activity.
|
||||||
for _project, period, _activity, note, minutes in self._last_rows:
|
gran = getattr(self, "_last_gran", "day")
|
||||||
per_period_minutes[period] += minutes
|
per_bucket_minutes: dict[str, int] = defaultdict(int)
|
||||||
|
for _project, period, activity, _note, minutes in self._last_rows:
|
||||||
|
bucket = activity if gran == "activity" else period
|
||||||
|
per_bucket_minutes[bucket] += minutes
|
||||||
|
|
||||||
periods = sorted(per_period_minutes.keys())
|
buckets = sorted(per_bucket_minutes.keys())
|
||||||
chart_w, chart_h = 800, 220
|
chart_w, chart_h = 800, 220
|
||||||
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
|
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
|
||||||
chart.fill(Qt.white)
|
chart.fill(Qt.white)
|
||||||
|
|
||||||
if periods:
|
if buckets:
|
||||||
painter = QPainter(chart)
|
painter = QPainter(chart)
|
||||||
try:
|
try:
|
||||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||||
|
|
@ -1317,9 +1452,9 @@ class TimeReportDialog(QDialog):
|
||||||
# Border
|
# Border
|
||||||
painter.drawRect(left, top, width, height)
|
painter.drawRect(left, top, width, height)
|
||||||
|
|
||||||
max_hours = max(per_period_minutes[p] for p in periods) / 60.0
|
max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
|
||||||
if max_hours > 0:
|
if max_hours > 0:
|
||||||
n = len(periods)
|
n = len(buckets)
|
||||||
bar_spacing = width / max(1, n)
|
bar_spacing = width / max(1, n)
|
||||||
bar_width = bar_spacing * 0.6
|
bar_width = bar_spacing * 0.6
|
||||||
|
|
||||||
|
|
@ -1344,8 +1479,8 @@ class TimeReportDialog(QDialog):
|
||||||
painter.setBrush(QColor(80, 140, 200))
|
painter.setBrush(QColor(80, 140, 200))
|
||||||
painter.setPen(Qt.NoPen)
|
painter.setPen(Qt.NoPen)
|
||||||
|
|
||||||
for i, period in enumerate(periods):
|
for i, label in enumerate(buckets):
|
||||||
hours = per_period_minutes[period] / 60.0
|
hours = per_bucket_minutes[label] / 60.0
|
||||||
bar_h = int((hours / max_hours) * (height - 10))
|
bar_h = int((hours / max_hours) * (height - 10))
|
||||||
if bar_h <= 0:
|
if bar_h <= 0:
|
||||||
continue # pragma: no cover
|
continue # pragma: no cover
|
||||||
|
|
@ -1358,7 +1493,7 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# X labels after bars, in black
|
# X labels after bars, in black
|
||||||
painter.setPen(Qt.black)
|
painter.setPen(Qt.black)
|
||||||
for i, period in enumerate(periods):
|
for i, label in enumerate(buckets):
|
||||||
x_center = left + bar_spacing * (i + 0.5)
|
x_center = left + bar_spacing * (i + 0.5)
|
||||||
x = int(x_center - bar_width / 2)
|
x = int(x_center - bar_width / 2)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
|
|
@ -1367,7 +1502,7 @@ class TimeReportDialog(QDialog):
|
||||||
int(bar_width),
|
int(bar_width),
|
||||||
20,
|
20,
|
||||||
Qt.AlignHCenter | Qt.AlignTop,
|
Qt.AlignHCenter | Qt.AlignTop,
|
||||||
period,
|
label,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
@ -1376,23 +1511,53 @@ class TimeReportDialog(QDialog):
|
||||||
project = html.escape(self._last_project_name or "")
|
project = html.escape(self._last_project_name or "")
|
||||||
start = html.escape(self._last_start or "")
|
start = html.escape(self._last_start or "")
|
||||||
end = html.escape(self._last_end or "")
|
end = html.escape(self._last_end or "")
|
||||||
gran = html.escape(self._last_gran_label or "")
|
gran_key = getattr(self, "_last_gran", "day")
|
||||||
|
gran_label = html.escape(self._last_gran_label or "")
|
||||||
|
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
|
|
||||||
# Table rows (period, activity, hours)
|
# Table rows
|
||||||
row_html_parts: list[str] = []
|
row_html_parts: list[str] = []
|
||||||
for project, period, activity, note, minutes in self._last_rows:
|
if gran_key == "activity":
|
||||||
hours = minutes / 60.0
|
for project, _period, activity, _note, minutes in self._last_rows:
|
||||||
row_html_parts.append(
|
hours = minutes / 60.0
|
||||||
|
row_html_parts.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(project)}</td>"
|
||||||
|
f"<td>{html.escape(activity)}</td>"
|
||||||
|
f"<td style='text-align:right'>{hours:.2f}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for project, period, activity, _note, minutes in self._last_rows:
|
||||||
|
hours = minutes / 60.0
|
||||||
|
row_html_parts.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(project)}</td>"
|
||||||
|
f"<td>{html.escape(period)}</td>"
|
||||||
|
f"<td>{html.escape(activity)}</td>"
|
||||||
|
f"<td style='text-align:right'>{hours:.2f}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
rows_html = "\n".join(row_html_parts)
|
||||||
|
|
||||||
|
if gran_key == "activity":
|
||||||
|
table_header_html = (
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td>{html.escape(project)}</td>"
|
f"<th>{html.escape(strings._('project'))}</th>"
|
||||||
f"<td>{html.escape(period)}</td>"
|
f"<th>{html.escape(strings._('activity'))}</th>"
|
||||||
f"<td>{html.escape(activity)}</td>"
|
f"<th>{html.escape(strings._('hours'))}</th>"
|
||||||
f"<td style='text-align:right'>{hours:.2f}</td>"
|
"</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
table_header_html = (
|
||||||
|
"<tr>"
|
||||||
|
f"<th>{html.escape(strings._('project'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('time_period'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('activity'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('hours'))}</th>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
rows_html = "\n".join(row_html_parts)
|
|
||||||
|
|
||||||
html_doc = f"""
|
html_doc = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
@ -1439,16 +1604,11 @@ class TimeReportDialog(QDialog):
|
||||||
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
|
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
{html.escape(strings._("time_log_report_meta").format(
|
{html.escape(strings._("time_log_report_meta").format(
|
||||||
start=start, end=end, granularity=gran))}
|
start=start, end=end, granularity=gran_label))}
|
||||||
</p>
|
</p>
|
||||||
<p><img src="chart" class="chart" /></p>
|
<p><img src="chart" class="chart" /></p>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
{table_header_html}
|
||||||
<th>{html.escape(strings._("project"))}</th>
|
|
||||||
<th>{html.escape(strings._("time_period"))}</th>
|
|
||||||
<th>{html.escape(strings._("activity"))}</th>
|
|
||||||
<th>{html.escape(strings._("hours"))}</th>
|
|
||||||
</tr>
|
|
||||||
{rows_html}
|
{rows_html}
|
||||||
</table>
|
</table>
|
||||||
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
|
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
|
||||||
|
|
@ -1475,3 +1635,55 @@ class TimeReportDialog(QDialog):
|
||||||
strings._("export_pdf_error_title"),
|
strings._("export_pdf_error_title"),
|
||||||
strings._("export_pdf_error_message").format(error=str(exc)),
|
strings._("export_pdf_error_message").format(error=str(exc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_invoice_button_state(self) -> None:
|
||||||
|
data = self.project_combo.currentData()
|
||||||
|
if data is not None:
|
||||||
|
self.invoice_btn.show()
|
||||||
|
else:
|
||||||
|
self.invoice_btn.hide()
|
||||||
|
|
||||||
|
def _on_manage_invoices(self) -> None:
|
||||||
|
from .invoices import InvoicesDialog
|
||||||
|
|
||||||
|
dlg = InvoicesDialog(self._db, parent=self)
|
||||||
|
|
||||||
|
# When the dialog says "reminders changed", forward that outward
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _on_create_invoice(self) -> None:
|
||||||
|
idx = self.project_combo.currentIndex()
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
project_id_data = self.project_combo.itemData(idx)
|
||||||
|
if project_id_data is None:
|
||||||
|
# Currently invoices are per-project, not cross-project
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_project_required_title"),
|
||||||
|
strings._("invoice_project_required_message"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
proj_id = int(project_id_data)
|
||||||
|
|
||||||
|
# Ensure we have a recent run to base this on
|
||||||
|
if not self._last_time_logs:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_need_report_title"),
|
||||||
|
strings._("invoice_need_report_message"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
start = self.from_date.date().toString("yyyy-MM-dd")
|
||||||
|
end = self.to_date.date().toString("yyyy-MM-dd")
|
||||||
|
|
||||||
|
from .invoices import InvoiceDialog
|
||||||
|
|
||||||
|
dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self)
|
||||||
|
dlg.remindersChanged.connect(self.remindersChanged.emit)
|
||||||
|
dlg.exec()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Signal, Qt
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
|
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
|
||||||
from PySide6.QtWidgets import QToolBar
|
from PySide6.QtWidgets import QToolBar
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,17 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec
|
import subprocess # nosec
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from importlib.resources import files
|
|
||||||
from PySide6.QtCore import QStandardPaths, Qt
|
from PySide6.QtCore import QStandardPaths, Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
|
||||||
QApplication,
|
|
||||||
QMessageBox,
|
|
||||||
QWidget,
|
|
||||||
QProgressDialog,
|
|
||||||
)
|
|
||||||
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
|
|
||||||
from PySide6.QtSvg import QSvgRenderer
|
from PySide6.QtSvg import QSvgRenderer
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
|
||||||
|
|
||||||
from .settings import APP_NAME
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
from .settings import APP_NAME
|
||||||
|
|
||||||
# Where to fetch the latest version string from
|
# Where to fetch the latest version string from
|
||||||
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
||||||
|
|
|
||||||
196
poetry.lock
generated
196
poetry.lock
generated
|
|
@ -146,103 +146,103 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.12.0"
|
version = "7.13.0"
|
||||||
description = "Code coverage measurement for Python"
|
description = "Code coverage measurement for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
|
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
|
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
|
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
|
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
|
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
|
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
|
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
|
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
|
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
|
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
|
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
|
||||||
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
|
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
|
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
|
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
|
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
|
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
|
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
|
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
|
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
|
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
|
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
|
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
|
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
|
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
|
||||||
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
|
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
|
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
|
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
|
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
|
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
|
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
|
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
|
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
|
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
|
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
|
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
|
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
|
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
|
||||||
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
|
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
|
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
|
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
|
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
|
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
|
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
|
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
|
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
|
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
|
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
|
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
|
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
|
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
|
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
|
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
|
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
|
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
|
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
|
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
|
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
|
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
|
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
|
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
|
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
|
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
|
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
|
||||||
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
|
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
|
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
|
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
|
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
|
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
|
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
|
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
|
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
|
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
|
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
|
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
|
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
|
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
|
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
|
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
|
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
|
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
|
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
|
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
|
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
|
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
|
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
|
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
|
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
|
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
|
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
|
||||||
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
|
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
|
||||||
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
|
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
|
||||||
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
|
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
@ -747,20 +747,20 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.5.0"
|
version = "2.6.2"
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[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)"]
|
h2 = ["h2 (>=4,<5)"]
|
||||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||||
zstd = ["zstandard (>=0.18.0)"]
|
zstd = ["backports-zstd (>=1.0.0)"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.6.3"
|
version = "0.7.3"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
34
release.sh
34
release.sh
|
|
@ -2,19 +2,47 @@
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
|
# Parse the args
|
||||||
|
while getopts "v:" OPTION
|
||||||
|
do
|
||||||
|
case $OPTION in
|
||||||
|
v)
|
||||||
|
VERSION=$OPTARG
|
||||||
|
;;
|
||||||
|
?)
|
||||||
|
usage
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
echo "You forgot to pass -v [version]!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
|
||||||
|
|
||||||
|
git add pyproject.toml
|
||||||
|
git commit -m "Bump to ${VERSION}"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
# Clean caches etc
|
# Clean caches etc
|
||||||
/home/user/venv-filedust/bin/filedust -y .
|
filedust -y .
|
||||||
|
|
||||||
# Publish to Pypi
|
# Publish to Pypi
|
||||||
poetry build
|
poetry build
|
||||||
poetry publish
|
poetry publish
|
||||||
|
|
||||||
# Make AppImage
|
# Make AppImage
|
||||||
sudo apt-get install libfuse-dev
|
sudo apt-get -y install libfuse-dev
|
||||||
poetry run pyproject-appimage
|
poetry run pyproject-appimage
|
||||||
mv Bouquin.AppImage dist/
|
mv Bouquin.AppImage dist/
|
||||||
|
|
||||||
# Sign packages
|
# Sign packages
|
||||||
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
||||||
|
|
||||||
echo "Don't forget to update version string on remote server."
|
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ def freeze_qt_time(monkeypatch):
|
||||||
QTime.currentTime().addSecs(3600) is still the same calendar day.
|
QTime.currentTime().addSecs(3600) is still the same calendar day.
|
||||||
"""
|
"""
|
||||||
import bouquin.main_window as _mwmod
|
import bouquin.main_window as _mwmod
|
||||||
from PySide6.QtCore import QDate, QTime, QDateTime
|
from PySide6.QtCore import QDate, QDateTime, QTime
|
||||||
|
|
||||||
today = QDate.currentDate()
|
today = QDate.currentDate()
|
||||||
fixed_time = QTime(12, 0)
|
fixed_time = QTime(12, 0)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import bouquin.bug_report_dialog as bugmod
|
import bouquin.bug_report_dialog as bugmod
|
||||||
from bouquin.bug_report_dialog import BugReportDialog
|
|
||||||
from bouquin import strings
|
from bouquin import strings
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from bouquin.bug_report_dialog import BugReportDialog
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QTextCursor
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
|
||||||
def test_bug_report_truncates_text_to_max_chars(qtbot):
|
def test_bug_report_truncates_text_to_max_chars(qtbot):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
from PySide6.QtWidgets import QPushButton
|
|
||||||
from bouquin import strings
|
from bouquin import strings
|
||||||
|
|
||||||
from PySide6.QtCore import QRect, QSize
|
|
||||||
from PySide6.QtGui import QPaintEvent, QFont
|
|
||||||
|
|
||||||
from bouquin.code_block_editor_dialog import (
|
from bouquin.code_block_editor_dialog import (
|
||||||
CodeBlockEditorDialog,
|
CodeBlockEditorDialog,
|
||||||
CodeEditorWithLineNumbers,
|
CodeEditorWithLineNumbers,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtCore import QRect, QSize
|
||||||
|
from PySide6.QtGui import QFont, QPaintEvent
|
||||||
|
from PySide6.QtWidgets import QPushButton
|
||||||
|
|
||||||
|
|
||||||
def _find_button_by_text(widget, text):
|
def _find_button_by_text(widget, text):
|
||||||
|
|
@ -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())
|
rect = QRect(0, 0, line_area.width(), line_area.height())
|
||||||
paint_event = QPaintEvent(rect)
|
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)
|
editor.line_number_area_paint_event(paint_event)
|
||||||
|
|
||||||
# Should not crash
|
# Should not crash
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
|
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
|
||||||
from PySide6.QtGui import QTextCharFormat, QFont
|
from PySide6.QtGui import QFont, QTextCharFormat
|
||||||
|
|
||||||
|
|
||||||
def test_get_language_patterns_python(app):
|
def test_get_language_patterns_python(app):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import pytest
|
import csv
|
||||||
import json, csv
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
import json
|
||||||
from bouquin.db import DBManager
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bouquin.db import DBManager
|
||||||
|
from sqlcipher3 import dbapi2 as sqlite
|
||||||
|
|
||||||
|
|
||||||
def _today():
|
def _today():
|
||||||
return dt.date.today().isoformat()
|
return dt.date.today().isoformat()
|
||||||
|
|
@ -371,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db):
|
||||||
"""Test gather_stats on empty database."""
|
"""Test gather_stats on empty database."""
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
|
|
||||||
assert len(stats) == 10
|
assert len(stats) == 22
|
||||||
(
|
(
|
||||||
pages_with_content,
|
pages_with_content,
|
||||||
total_revisions,
|
total_revisions,
|
||||||
|
|
@ -383,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db):
|
||||||
page_most_tags,
|
page_most_tags,
|
||||||
page_most_tags_count,
|
page_most_tags_count,
|
||||||
revisions_by_date,
|
revisions_by_date,
|
||||||
|
time_minutes_by_date,
|
||||||
|
total_time_minutes,
|
||||||
|
day_most_time,
|
||||||
|
day_most_time_minutes,
|
||||||
|
project_most_minutes_name,
|
||||||
|
project_most_minutes,
|
||||||
|
activity_most_minutes_name,
|
||||||
|
activity_most_minutes,
|
||||||
|
reminders_by_date,
|
||||||
|
total_reminders,
|
||||||
|
day_most_reminders,
|
||||||
|
day_most_reminders_count,
|
||||||
) = stats
|
) = stats
|
||||||
|
|
||||||
assert pages_with_content == 0
|
assert pages_with_content == 0
|
||||||
|
|
@ -419,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db):
|
||||||
page_most_tags,
|
page_most_tags,
|
||||||
page_most_tags_count,
|
page_most_tags_count,
|
||||||
revisions_by_date,
|
revisions_by_date,
|
||||||
|
*_rest,
|
||||||
) = stats
|
) = stats
|
||||||
|
|
||||||
assert pages_with_content == 2
|
assert pages_with_content == 2
|
||||||
|
|
@ -435,7 +450,7 @@ def test_db_gather_stats_word_counting(fresh_db):
|
||||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
|
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
|
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
|
||||||
|
|
||||||
assert total_words == 5
|
assert total_words == 5
|
||||||
|
|
||||||
|
|
@ -461,7 +476,7 @@ def test_db_gather_stats_with_tags(fresh_db):
|
||||||
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
|
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
|
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
|
||||||
|
|
||||||
assert unique_tags == 3
|
assert unique_tags == 3
|
||||||
assert page_most_tags == "2024-01-01"
|
assert page_most_tags == "2024-01-01"
|
||||||
|
|
@ -477,7 +492,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db):
|
||||||
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
|
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||||
|
|
||||||
assert date(2024, 1, 1) in revisions_by_date
|
assert date(2024, 1, 1) in revisions_by_date
|
||||||
assert revisions_by_date[date(2024, 1, 1)] == 3
|
assert revisions_by_date[date(2024, 1, 1)] == 3
|
||||||
|
|
@ -492,7 +507,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db):
|
||||||
fresh_db.save_new_version("2024-01-15", "Test", "v1")
|
fresh_db.save_new_version("2024-01-15", "Test", "v1")
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||||
|
|
||||||
# Should have parsed the date correctly
|
# Should have parsed the date correctly
|
||||||
assert date(2024, 1, 15) in revisions_by_date
|
assert date(2024, 1, 15) in revisions_by_date
|
||||||
|
|
@ -505,7 +520,7 @@ def test_db_gather_stats_current_version_only(fresh_db):
|
||||||
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
|
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
|
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
|
||||||
|
|
||||||
# Should count words from current version (5 words), not old version
|
# Should count words from current version (5 words), not old version
|
||||||
assert total_words == 5
|
assert total_words == 5
|
||||||
|
|
@ -517,7 +532,7 @@ def test_db_gather_stats_no_tags(fresh_db):
|
||||||
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
|
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
|
||||||
|
|
||||||
stats = fresh_db.gather_stats()
|
stats = fresh_db.gather_stats()
|
||||||
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
|
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
|
||||||
|
|
||||||
assert unique_tags == 0
|
assert unique_tags == 0
|
||||||
assert page_most_tags is None
|
assert page_most_tags is None
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from unittest.mock import patch
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from PySide6.QtCore import QUrl
|
from PySide6.QtCore import QUrl
|
||||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||||
|
|
||||||
|
|
||||||
def test_open_document_from_db_success(qtbot, app, fresh_db):
|
def test_open_document_from_db_success(qtbot, app, fresh_db):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bouquin.db import DBConfig
|
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.QtCore import Qt, QUrl
|
||||||
from PySide6.QtWidgets import QMessageBox, QDialog, QFileDialog
|
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TodaysDocumentsWidget Tests
|
# TodaysDocumentsWidget Tests
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from bouquin.find_bar import FindBar
|
||||||
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QTextCursor
|
||||||
from PySide6.QtWidgets import QTextEdit, QWidget
|
from PySide6.QtWidgets import QTextEdit, QWidget
|
||||||
from bouquin.markdown_editor import MarkdownEditor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
|
||||||
from bouquin.find_bar import FindBar
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
|
|
||||||
from PySide6.QtCore import Qt, QTimer
|
|
||||||
|
|
||||||
from bouquin.history_dialog import HistoryDialog
|
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):
|
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
||||||
|
|
|
||||||
1346
tests/test_invoices.py
Normal file
1346
tests/test_invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,4 @@
|
||||||
from bouquin.key_prompt import KeyPrompt
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
from PySide6.QtWidgets import QFileDialog, QLineEdit
|
from PySide6.QtWidgets import QFileDialog, QLineEdit
|
||||||
|
|
||||||
|
|
@ -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):
|
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)
|
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
||||||
qtbot.addWidget(prompt)
|
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):
|
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 = tmp_path / "initial.db"
|
||||||
initial_db.touch()
|
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
|
# Mock the file dialog to return a different file
|
||||||
def mock_get_open_filename(*args, **kwargs):
|
def mock_get_open_filename(*args, **kwargs):
|
||||||
# Verify that start_dir was passed correctly (line 57)
|
# Verify that start_dir was passed correctly
|
||||||
return str(new_db), "SQLCipher DB (*.db)"
|
return str(new_db), "SQLCipher DB (*.db)"
|
||||||
|
|
||||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
from bouquin.lock_overlay import LockOverlay
|
||||||
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
from PySide6.QtCore import QEvent
|
from PySide6.QtCore import QEvent
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
from bouquin.lock_overlay import LockOverlay
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
|
||||||
|
|
||||||
|
|
||||||
def test_lock_overlay_reacts_to_theme(app, qtbot):
|
def test_lock_overlay_reacts_to_theme(app, qtbot):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import importlib
|
import importlib
|
||||||
import runpy
|
import runpy
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import pytest
|
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
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
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import bouquin.main_window as mwmod
|
||||||
import bouquin.version_check as version_check
|
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):
|
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
|
|
@ -2229,7 +2226,7 @@ def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
assert window.tab_widget.count() == initial_count - 1
|
assert window.tab_widget.count() == initial_count - 1
|
||||||
|
|
||||||
|
|
||||||
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
|
def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||||
"""Test parsing inline alarms from markdown (⏰ HH:MM format)."""
|
"""Test parsing inline alarms from markdown (⏰ HH:MM format)."""
|
||||||
from PySide6.QtCore import QTime
|
from PySide6.QtCore import QTime
|
||||||
|
|
||||||
|
|
@ -2255,7 +2252,7 @@ def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
window._open_date_in_tab(today_qdate)
|
window._open_date_in_tab(today_qdate)
|
||||||
|
|
||||||
# Set content with a future alarm
|
# Set content with a future alarm
|
||||||
future_time = QTime.currentTime().addSecs(3600) # 1 hour from now
|
future_time = QTime.currentTime().addSecs(3600)
|
||||||
alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
|
alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
|
||||||
|
|
||||||
# Set the editor's current_date attribute
|
# Set the editor's current_date attribute
|
||||||
|
|
@ -2311,7 +2308,7 @@ def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
assert len(window._reminder_timers) == 0
|
assert len(window._reminder_timers) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
|
def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||||
"""Test that past alarms are skipped."""
|
"""Test that past alarms are skipped."""
|
||||||
from PySide6.QtCore import QTime
|
from PySide6.QtCore import QTime
|
||||||
|
|
||||||
|
|
@ -2353,7 +2350,7 @@ def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
assert len(window._reminder_timers) == 0
|
assert len(window._reminder_timers) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db):
|
def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
|
||||||
"""Test alarm with no text before emoji uses fallback."""
|
"""Test alarm with no text before emoji uses fallback."""
|
||||||
from PySide6.QtCore import QTime
|
from PySide6.QtCore import QTime
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
import pytest
|
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_editor import MarkdownEditor
|
||||||
from bouquin.markdown_highlighter import MarkdownHighlighter
|
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():
|
def _today():
|
||||||
|
|
@ -1575,7 +1574,7 @@ def test_markdown_highlighter_special_characters(qtbot, app):
|
||||||
highlighter = MarkdownHighlighter(doc, theme_manager)
|
highlighter = MarkdownHighlighter(doc, theme_manager)
|
||||||
|
|
||||||
text = """
|
text = """
|
||||||
Special chars: < > & " '
|
Special chars: < > & " '
|
||||||
Escaped: \\* \\_ \\`
|
Escaped: \\* \\_ \\`
|
||||||
Unicode: 你好 café résumé
|
Unicode: 你好 café résumé
|
||||||
"""
|
"""
|
||||||
|
|
@ -1928,7 +1927,7 @@ def test_editor_delete_operations(qtbot, app):
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_highlighter_dark_theme(qtbot, app):
|
def test_markdown_highlighter_dark_theme(qtbot, app):
|
||||||
"""Test markdown highlighter with dark theme - covers lines 74-75"""
|
"""Test markdown highlighter with dark theme"""
|
||||||
# Create theme manager with dark theme
|
# Create theme manager with dark theme
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
||||||
|
|
||||||
|
|
@ -2293,7 +2292,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
editor.highlighter.rehighlight()
|
editor.highlighter.rehighlight()
|
||||||
|
|
||||||
# Verify syntax highlighting was applied (lines 186-193)
|
# Verify syntax highlighting was applied
|
||||||
# We can't easily verify the exact formatting, but we ensure no crash
|
# We can't easily verify the exact formatting, but we ensure no crash
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2305,13 +2304,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
editor.highlighter.rehighlight()
|
editor.highlighter.rehighlight()
|
||||||
|
|
||||||
# The overlap detection (lines 252, 264) should prevent issues
|
|
||||||
|
|
||||||
|
|
||||||
def test_highlighter_italic_edge_cases(editor, qtbot):
|
def test_highlighter_italic_edge_cases(editor, qtbot):
|
||||||
"""Test italic formatting edge cases."""
|
"""Test italic formatting edge cases."""
|
||||||
# Test edge case: avoiding stealing markers that are part of double
|
# Test edge case: avoiding stealing markers that are part of double
|
||||||
# This tests lines 267-270
|
|
||||||
editor.setPlainText("**not italic* text**")
|
editor.setPlainText("**not italic* text**")
|
||||||
|
|
||||||
# Force rehighlight
|
# Force rehighlight
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,18 @@ These tests should be added to test_markdown_editor.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
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 (
|
from PySide6.QtGui import (
|
||||||
QImage,
|
|
||||||
QColor,
|
QColor,
|
||||||
|
QImage,
|
||||||
QKeyEvent,
|
QKeyEvent,
|
||||||
|
QMouseEvent,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
QTextDocument,
|
QTextDocument,
|
||||||
QMouseEvent,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from bouquin.markdown_editor import MarkdownEditor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
|
||||||
|
|
||||||
|
|
||||||
def text(editor) -> str:
|
def text(editor) -> str:
|
||||||
return editor.toPlainText()
|
return editor.toPlainText()
|
||||||
|
|
@ -44,7 +43,6 @@ def editor(app, qtbot):
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
|
||||||
# Test for line 215: document is None guard
|
|
||||||
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||||
"""Test _update_code_block_row_backgrounds when document is None."""
|
"""Test _update_code_block_row_backgrounds when document is None."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -60,7 +58,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||||
editor._update_code_block_row_backgrounds()
|
editor._update_code_block_row_backgrounds()
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases
|
|
||||||
def test_find_code_block_bounds_invalid_block(editor):
|
def test_find_code_block_bounds_invalid_block(editor):
|
||||||
"""Test _find_code_block_bounds with invalid block."""
|
"""Test _find_code_block_bounds with invalid block."""
|
||||||
editor.setPlainText("some text")
|
editor.setPlainText("some text")
|
||||||
|
|
@ -124,7 +121,6 @@ def test_find_code_block_bounds_no_opening_fence(editor):
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases
|
|
||||||
def test_edit_code_block_checks_document(app, qtbot):
|
def test_edit_code_block_checks_document(app, qtbot):
|
||||||
"""Test _edit_code_block when editor has no document."""
|
"""Test _edit_code_block when editor has no document."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -148,8 +144,8 @@ def test_edit_code_block_checks_document(app, qtbot):
|
||||||
|
|
||||||
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
|
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
|
||||||
"""Test _edit_code_block when dialog is cancelled."""
|
"""Test _edit_code_block when dialog is cancelled."""
|
||||||
from PySide6.QtWidgets import QDialog
|
|
||||||
import bouquin.markdown_editor as markdown_editor
|
import bouquin.markdown_editor as markdown_editor
|
||||||
|
from PySide6.QtWidgets import QDialog
|
||||||
|
|
||||||
class CancelledDialog:
|
class CancelledDialog:
|
||||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
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):
|
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
|
||||||
"""Test _edit_code_block when user deletes the block."""
|
"""Test _edit_code_block when user deletes the block."""
|
||||||
from PySide6.QtWidgets import QDialog
|
|
||||||
import bouquin.markdown_editor as markdown_editor
|
import bouquin.markdown_editor as markdown_editor
|
||||||
|
from PySide6.QtWidgets import QDialog
|
||||||
|
|
||||||
class DeleteDialog:
|
class DeleteDialog:
|
||||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
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):
|
def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
|
||||||
"""Test _edit_code_block with language change."""
|
"""Test _edit_code_block with language change."""
|
||||||
from PySide6.QtWidgets import QDialog
|
|
||||||
import bouquin.markdown_editor as markdown_editor
|
import bouquin.markdown_editor as markdown_editor
|
||||||
|
from PySide6.QtWidgets import QDialog
|
||||||
|
|
||||||
class LanguageChangeDialog:
|
class LanguageChangeDialog:
|
||||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
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"
|
assert lang == "javascript"
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 443-490: _delete_code_block
|
|
||||||
def test_delete_code_block_no_bounds(editor):
|
def test_delete_code_block_no_bounds(editor):
|
||||||
"""Test _delete_code_block when bounds can't be found."""
|
"""Test _delete_code_block when bounds can't be found."""
|
||||||
editor.setPlainText("not a code block")
|
editor.setPlainText("not a code block")
|
||||||
|
|
@ -307,7 +302,6 @@ def test_delete_code_block_with_text_after(editor):
|
||||||
assert "text after" in new_text
|
assert "text after" in new_text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 496: _apply_line_spacing with no document
|
|
||||||
def test_apply_line_spacing_no_document(app):
|
def test_apply_line_spacing_no_document(app):
|
||||||
"""Test _apply_line_spacing when document is None."""
|
"""Test _apply_line_spacing when document is None."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -319,7 +313,6 @@ def test_apply_line_spacing_no_document(app):
|
||||||
editor._apply_line_spacing(125.0)
|
editor._apply_line_spacing(125.0)
|
||||||
|
|
||||||
|
|
||||||
# Test for line 517: _apply_code_block_spacing
|
|
||||||
def test_apply_code_block_spacing(editor):
|
def test_apply_code_block_spacing(editor):
|
||||||
"""Test _apply_code_block_spacing applies correct spacing."""
|
"""Test _apply_code_block_spacing applies correct spacing."""
|
||||||
editor.setPlainText("```\nline1\nline2\n```")
|
editor.setPlainText("```\nline1\nline2\n```")
|
||||||
|
|
@ -334,7 +327,6 @@ def test_apply_code_block_spacing(editor):
|
||||||
assert block.isValid()
|
assert block.isValid()
|
||||||
|
|
||||||
|
|
||||||
# Test for line 604: to_markdown with metadata
|
|
||||||
def test_to_markdown_with_code_metadata(editor):
|
def test_to_markdown_with_code_metadata(editor):
|
||||||
"""Test to_markdown includes code block metadata."""
|
"""Test to_markdown includes code block metadata."""
|
||||||
editor.setPlainText("```python\ncode\n```")
|
editor.setPlainText("```python\ncode\n```")
|
||||||
|
|
@ -348,7 +340,6 @@ def test_to_markdown_with_code_metadata(editor):
|
||||||
assert "code-langs" in md or "code" in md
|
assert "code-langs" in md or "code" in md
|
||||||
|
|
||||||
|
|
||||||
# Test for line 648: from_markdown without _code_metadata attribute
|
|
||||||
def test_from_markdown_creates_code_metadata(app):
|
def test_from_markdown_creates_code_metadata(app):
|
||||||
"""Test from_markdown creates _code_metadata if missing."""
|
"""Test from_markdown creates _code_metadata if missing."""
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
|
@ -364,7 +355,6 @@ def test_from_markdown_creates_code_metadata(app):
|
||||||
assert hasattr(editor, "_code_metadata")
|
assert hasattr(editor, "_code_metadata")
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 718-736: image embedding with original size
|
|
||||||
def test_embed_images_preserves_original_size(editor, tmp_path):
|
def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||||
"""Test that embedded images preserve their original dimensions."""
|
"""Test that embedded images preserve their original dimensions."""
|
||||||
# Create a test image
|
# Create a test image
|
||||||
|
|
@ -387,7 +377,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||||
assert doc is not None
|
assert doc is not None
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection
|
|
||||||
def test_trim_list_prefix_no_selection(editor):
|
def test_trim_list_prefix_no_selection(editor):
|
||||||
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -447,7 +436,6 @@ def test_trim_list_prefix_during_adjustment(editor):
|
||||||
editor._adjusting_selection = False
|
editor._adjusting_selection = False
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 848, 860-866: _detect_list_type
|
|
||||||
def test_detect_list_type_checkbox_checked(editor):
|
def test_detect_list_type_checkbox_checked(editor):
|
||||||
"""Test _detect_list_type with checked checkbox."""
|
"""Test _detect_list_type with checked checkbox."""
|
||||||
list_type, prefix = editor._detect_list_type(
|
list_type, prefix = editor._detect_list_type(
|
||||||
|
|
@ -478,7 +466,6 @@ def test_detect_list_type_not_a_list(editor):
|
||||||
assert prefix == ""
|
assert prefix == ""
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 876, 884-886: list prefix length calculation
|
|
||||||
def test_list_prefix_length_numbered(editor):
|
def test_list_prefix_length_numbered(editor):
|
||||||
"""Test _list_prefix_length_for_block with numbered list."""
|
"""Test _list_prefix_length_for_block with numbered list."""
|
||||||
editor.setPlainText("123. item")
|
editor.setPlainText("123. item")
|
||||||
|
|
@ -489,7 +476,6 @@ def test_list_prefix_length_numbered(editor):
|
||||||
assert length > 0
|
assert length > 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 948-949: keyPressEvent with Ctrl+Home
|
|
||||||
def test_key_press_ctrl_home(editor, qtbot):
|
def test_key_press_ctrl_home(editor, qtbot):
|
||||||
"""Test Ctrl+Home key combination."""
|
"""Test Ctrl+Home key combination."""
|
||||||
editor.setPlainText("line1\nline2\nline3")
|
editor.setPlainText("line1\nline2\nline3")
|
||||||
|
|
@ -504,7 +490,6 @@ def test_key_press_ctrl_home(editor, qtbot):
|
||||||
assert editor.textCursor().position() == 0
|
assert editor.textCursor().position() == 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 957-960: keyPressEvent with Ctrl+Left
|
|
||||||
def test_key_press_ctrl_left(editor, qtbot):
|
def test_key_press_ctrl_left(editor, qtbot):
|
||||||
"""Test Ctrl+Left key combination."""
|
"""Test Ctrl+Left key combination."""
|
||||||
editor.setPlainText("word1 word2 word3")
|
editor.setPlainText("word1 word2 word3")
|
||||||
|
|
@ -518,7 +503,6 @@ def test_key_press_ctrl_left(editor, qtbot):
|
||||||
# Should move left by word
|
# Should move left by word
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 984-988, 1044: Home key in list
|
|
||||||
def test_key_press_home_in_list(editor, qtbot):
|
def test_key_press_home_in_list(editor, qtbot):
|
||||||
"""Test Home key in list item."""
|
"""Test Home key in list item."""
|
||||||
editor.setPlainText("- item text")
|
editor.setPlainText("- item text")
|
||||||
|
|
@ -534,7 +518,6 @@ def test_key_press_home_in_list(editor, qtbot):
|
||||||
assert pos > 0
|
assert pos > 0
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1067-1073: Left key in list prefix
|
|
||||||
def test_key_press_left_in_list_prefix(editor, qtbot):
|
def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||||
"""Test Left key when in list prefix region."""
|
"""Test Left key when in list prefix region."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -549,7 +532,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||||
# Should snap to after prefix
|
# Should snap to after prefix
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1088, 1095-1104: Up/Down in code blocks
|
|
||||||
def test_key_press_up_in_code_block(editor, qtbot):
|
def test_key_press_up_in_code_block(editor, qtbot):
|
||||||
"""Test Up key inside code block."""
|
"""Test Up key inside code block."""
|
||||||
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
||||||
|
|
@ -579,7 +561,6 @@ def test_key_press_down_in_list_item(editor, qtbot):
|
||||||
# Should snap to after prefix on next line
|
# Should snap to after prefix on next line
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1127-1130, 1134-1137: Enter key with markers
|
|
||||||
def test_key_press_enter_after_markers(editor, qtbot):
|
def test_key_press_enter_after_markers(editor, qtbot):
|
||||||
"""Test Enter key after style markers."""
|
"""Test Enter key after style markers."""
|
||||||
editor.setPlainText("text **")
|
editor.setPlainText("text **")
|
||||||
|
|
@ -593,7 +574,6 @@ def test_key_press_enter_after_markers(editor, qtbot):
|
||||||
# Should handle markers
|
# Should handle markers
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1146-1164: Enter on fence line
|
|
||||||
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||||
"""Test Enter key on closing fence line."""
|
"""Test Enter key on closing fence line."""
|
||||||
editor.setPlainText("```\ncode\n```")
|
editor.setPlainText("```\ncode\n```")
|
||||||
|
|
@ -608,7 +588,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||||
# Should create new line after fence
|
# Should create new line after fence
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1185-1189: Backspace in empty checkbox
|
|
||||||
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||||
"""Test Backspace in empty checkbox item."""
|
"""Test Backspace in empty checkbox item."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
||||||
|
|
@ -622,7 +601,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||||
# Should remove checkbox
|
# Should remove checkbox
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1205, 1215-1221: Backspace in numbered list
|
|
||||||
def test_key_press_backspace_numbered_list(editor, qtbot):
|
def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||||
"""Test Backspace at start of numbered list item."""
|
"""Test Backspace at start of numbered list item."""
|
||||||
editor.setPlainText("1. ")
|
editor.setPlainText("1. ")
|
||||||
|
|
@ -634,7 +612,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||||
editor.keyPressEvent(event)
|
editor.keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists
|
|
||||||
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
||||||
"""Test Tab key in bullet list."""
|
"""Test Tab key in bullet list."""
|
||||||
editor.setPlainText("- item")
|
editor.setPlainText("- item")
|
||||||
|
|
@ -672,7 +649,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot):
|
||||||
editor.keyPressEvent(event)
|
editor.keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1282-1283: Auto-pairing skip
|
|
||||||
def test_apply_weight_to_selection(editor, qtbot):
|
def test_apply_weight_to_selection(editor, qtbot):
|
||||||
"""Test apply_weight makes text bold."""
|
"""Test apply_weight makes text bold."""
|
||||||
editor.setPlainText("text to bold")
|
editor.setPlainText("text to bold")
|
||||||
|
|
@ -712,7 +688,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot):
|
||||||
assert "~~" in md
|
assert "~~" in md
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks
|
|
||||||
def test_apply_code_on_selection(editor, qtbot):
|
def test_apply_code_on_selection(editor, qtbot):
|
||||||
"""Test apply_code with selected text."""
|
"""Test apply_code with selected text."""
|
||||||
editor.setPlainText("some code")
|
editor.setPlainText("some code")
|
||||||
|
|
@ -728,7 +703,6 @@ def test_apply_code_on_selection(editor, qtbot):
|
||||||
# May contain code block elements depending on dialog behavior
|
# May contain code block elements depending on dialog behavior
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1386: toggle_numbers
|
|
||||||
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||||
"""Test toggle_numbers converts text to numbered list."""
|
"""Test toggle_numbers converts text to numbered list."""
|
||||||
editor.setPlainText("item 1")
|
editor.setPlainText("item 1")
|
||||||
|
|
@ -742,7 +716,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||||
assert "1." in text
|
assert "1." in text
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1402-1407: toggle_bullets
|
|
||||||
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
||||||
"""Test toggle_bullets converts text to bullet list."""
|
"""Test toggle_bullets converts text to bullet list."""
|
||||||
editor.setPlainText("item 1")
|
editor.setPlainText("item 1")
|
||||||
|
|
@ -771,7 +744,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot):
|
||||||
assert text.strip() == "item 1"
|
assert text.strip() == "item 1"
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1429: toggle_checkboxes
|
|
||||||
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||||
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
||||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||||
|
|
@ -786,7 +758,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1452: apply_heading
|
|
||||||
def test_apply_heading_various_levels(editor, qtbot):
|
def test_apply_heading_various_levels(editor, qtbot):
|
||||||
"""Test apply_heading with different levels."""
|
"""Test apply_heading with different levels."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
|
|
@ -809,7 +780,6 @@ def test_apply_heading_various_levels(editor, qtbot):
|
||||||
assert text.startswith(expected_marker)
|
assert text.startswith(expected_marker)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1501-1505: insert_image_from_path
|
|
||||||
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
||||||
"""Test insert_image_from_path with invalid extension."""
|
"""Test insert_image_from_path with invalid extension."""
|
||||||
invalid_file = tmp_path / "file.txt"
|
invalid_file = tmp_path / "file.txt"
|
||||||
|
|
@ -827,7 +797,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path):
|
||||||
editor.insert_image_from_path(nonexistent)
|
editor.insert_image_from_path(nonexistent)
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1578-1579: mousePressEvent checkbox toggle
|
|
||||||
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
||||||
"""Test clicking checkbox toggles it from unchecked to checked."""
|
"""Test clicking checkbox toggles it from unchecked to checked."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||||
|
|
@ -872,7 +841,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
|
||||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||||
|
|
||||||
|
|
||||||
# Test for line 1602: mouseDoubleClickEvent
|
|
||||||
def test_mouse_double_click_suppression(editor, qtbot):
|
def test_mouse_double_click_suppression(editor, qtbot):
|
||||||
"""Test double-click suppression for checkboxes."""
|
"""Test double-click suppression for checkboxes."""
|
||||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||||
|
|
@ -895,7 +863,6 @@ def test_mouse_double_click_suppression(editor, qtbot):
|
||||||
assert not editor._suppress_next_checkbox_double_click
|
assert not editor._suppress_next_checkbox_double_click
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling)
|
|
||||||
def test_context_menu_in_code_block(editor, qtbot):
|
def test_context_menu_in_code_block(editor, qtbot):
|
||||||
"""Test context menu when in code block."""
|
"""Test context menu when in code block."""
|
||||||
editor.setPlainText("```python\ncode\n```")
|
editor.setPlainText("```python\ncode\n```")
|
||||||
|
|
@ -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
|
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1742-1757: _set_code_block_language
|
|
||||||
def test_set_code_block_language(editor, qtbot):
|
def test_set_code_block_language(editor, qtbot):
|
||||||
"""Test _set_code_block_language sets metadata."""
|
"""Test _set_code_block_language sets metadata."""
|
||||||
editor.setPlainText("```\ncode\n```")
|
editor.setPlainText("```\ncode\n```")
|
||||||
|
|
@ -929,7 +895,6 @@ def test_set_code_block_language(editor, qtbot):
|
||||||
assert lang == "python"
|
assert lang == "python"
|
||||||
|
|
||||||
|
|
||||||
# Test for lines 1770-1783: get_current_line_task_text
|
|
||||||
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
||||||
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
from PySide6.QtGui import QAction
|
from PySide6.QtGui import QAction
|
||||||
|
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
class DummyTimeLogWidget(QWidget):
|
class DummyTimeLogWidget(QWidget):
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
@pytest.fixture
|
||||||
|
|
@ -415,17 +414,6 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
|
||||||
widget._check_reminders()
|
widget._check_reminders()
|
||||||
|
|
||||||
|
|
||||||
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
|
|
||||||
"""Test starting the regular check timer."""
|
|
||||||
widget = UpcomingRemindersWidget(fresh_db)
|
|
||||||
qtbot.addWidget(widget)
|
|
||||||
|
|
||||||
widget._start_regular_timer()
|
|
||||||
|
|
||||||
# Timer should be running
|
|
||||||
assert widget._check_timer.isActive()
|
|
||||||
|
|
||||||
|
|
||||||
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
|
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
|
||||||
"""Test ManageRemindersDialog initialization."""
|
"""Test ManageRemindersDialog initialization."""
|
||||||
dialog = ManageRemindersDialog(fresh_db)
|
dialog = ManageRemindersDialog(fresh_db)
|
||||||
|
|
@ -587,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
# Check that the type column shows the day
|
# Check that the type column shows the day
|
||||||
type_item = dialog.table.item(0, 2)
|
type_item = dialog.table.item(0, 3)
|
||||||
assert "Wed" in type_item.text()
|
assert "Wed" in type_item.text()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -851,9 +839,9 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
|
||||||
def test_upcoming_reminders_context_menu_shows(
|
def test_upcoming_reminders_context_menu_shows(
|
||||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||||
):
|
):
|
||||||
from PySide6 import QtWidgets, QtGui
|
|
||||||
from PySide6.QtCore import QPoint
|
|
||||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||||
|
from PySide6 import QtGui, QtWidgets
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
|
||||||
# Add a future reminder for today
|
# Add a future reminder for today
|
||||||
r = Reminder(
|
r = Reminder(
|
||||||
|
|
@ -909,9 +897,9 @@ def test_upcoming_reminders_context_menu_shows(
|
||||||
def test_upcoming_reminders_delete_selected_dedupes(
|
def test_upcoming_reminders_delete_selected_dedupes(
|
||||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||||
):
|
):
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
from PySide6.QtCore import QItemSelectionModel
|
|
||||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||||
|
from PySide6.QtCore import QItemSelectionModel
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
r = Reminder(
|
r = Reminder(
|
||||||
id=None,
|
id=None,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
from bouquin.settings import (
|
|
||||||
get_settings,
|
|
||||||
load_db_config,
|
|
||||||
save_db_config,
|
|
||||||
)
|
|
||||||
from bouquin.db import DBConfig
|
from bouquin.db import DBConfig
|
||||||
|
from bouquin.settings import get_settings, load_db_config, save_db_config
|
||||||
|
|
||||||
|
|
||||||
def _clear_db_settings():
|
def _clear_db_settings():
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
from bouquin.db import DBManager, DBConfig
|
|
||||||
from bouquin.key_prompt import KeyPrompt
|
|
||||||
import bouquin.settings_dialog as sd
|
import bouquin.settings_dialog as sd
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
from bouquin.db import DBConfig, DBManager
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.key_prompt import KeyPrompt
|
||||||
from bouquin.settings import get_settings
|
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.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):
|
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from bouquin import strings
|
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 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:
|
class FakeStatsDB:
|
||||||
|
|
@ -16,6 +14,7 @@ class FakeStatsDB:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
d1 = _dt.date(2024, 1, 1)
|
d1 = _dt.date(2024, 1, 1)
|
||||||
d2 = _dt.date(2024, 1, 2)
|
d2 = _dt.date(2024, 1, 2)
|
||||||
|
|
||||||
self.stats = (
|
self.stats = (
|
||||||
2, # pages_with_content
|
2, # pages_with_content
|
||||||
5, # total_revisions
|
5, # total_revisions
|
||||||
|
|
@ -27,7 +26,20 @@ class FakeStatsDB:
|
||||||
"2024-01-02", # page_most_tags
|
"2024-01-02", # page_most_tags
|
||||||
2, # page_most_tags_count
|
2, # page_most_tags_count
|
||||||
{d1: 1, d2: 2}, # revisions_by_date
|
{d1: 1, d2: 2}, # revisions_by_date
|
||||||
|
{d1: 60, d2: 120}, # time_minutes_by_date
|
||||||
|
180, # total_time_minutes
|
||||||
|
"2024-01-02", # day_most_time
|
||||||
|
120, # day_most_time_minutes
|
||||||
|
"Project A", # project_most_minutes_name
|
||||||
|
120, # project_most_minutes
|
||||||
|
"Activity A", # activity_most_minutes_name
|
||||||
|
120, # activity_most_minutes
|
||||||
|
{d1: 1, d2: 3}, # reminders_by_date
|
||||||
|
4, # total_reminders
|
||||||
|
"2024-01-02", # day_most_reminders
|
||||||
|
3, # day_most_reminders_count
|
||||||
)
|
)
|
||||||
|
|
||||||
self.called = False
|
self.called = False
|
||||||
|
|
||||||
def gather_stats(self):
|
def gather_stats(self):
|
||||||
|
|
@ -59,7 +71,7 @@ def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
|
||||||
|
|
||||||
# Heatmap is created and uses "words" by default
|
# Heatmap is created and uses "words" by default
|
||||||
words_by_date = db.stats[4]
|
words_by_date = db.stats[4]
|
||||||
revisions_by_date = db.stats[-1]
|
revisions_by_date = db.stats[9]
|
||||||
|
|
||||||
assert hasattr(dlg, "_heatmap")
|
assert hasattr(dlg, "_heatmap")
|
||||||
assert dlg._heatmap._data == words_by_date
|
assert dlg._heatmap._data == words_by_date
|
||||||
|
|
@ -82,13 +94,25 @@ class EmptyStatsDB:
|
||||||
0, # pages_with_content
|
0, # pages_with_content
|
||||||
0, # total_revisions
|
0, # total_revisions
|
||||||
None, # page_most_revisions
|
None, # page_most_revisions
|
||||||
0,
|
0, # page_most_revisions_count
|
||||||
{}, # words_by_date
|
{}, # words_by_date
|
||||||
0, # total_words
|
0, # total_words
|
||||||
0, # unique_tags
|
0, # unique_tags
|
||||||
None, # page_most_tags
|
None, # page_most_tags
|
||||||
0,
|
0, # page_most_tags_count
|
||||||
{}, # revisions_by_date
|
{}, # revisions_by_date
|
||||||
|
{}, # time_minutes_by_date
|
||||||
|
0, # total_time_minutes
|
||||||
|
None, # day_most_time
|
||||||
|
0, # day_most_time_minutes
|
||||||
|
None, # project_most_minutes_name
|
||||||
|
0, # project_most_minutes
|
||||||
|
None, # activity_most_minutes_name
|
||||||
|
0, # activity_most_minutes
|
||||||
|
{}, # reminders_by_date
|
||||||
|
0, # total_reminders
|
||||||
|
None, # day_most_reminders
|
||||||
|
0, # day_most_reminders_count
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -632,5 +656,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db):
|
||||||
# Force a repaint to execute paintEvent
|
# Force a repaint to execute paintEvent
|
||||||
heatmap.repaint()
|
heatmap.repaint()
|
||||||
|
|
||||||
# The month continuation logic (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
|
# We can't easily test the visual output, but we ensure no crash
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import types
|
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.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):
|
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
|
import bouquin.strings as strings
|
||||||
import pytest
|
import pytest
|
||||||
|
from bouquin.db import DBManager
|
||||||
from PySide6.QtCore import Qt, QPoint, QEvent, QDate
|
from bouquin.flow_layout import FlowLayout
|
||||||
from PySide6.QtGui import QMouseEvent, QColor
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QMessageBox,
|
|
||||||
QInputDialog,
|
|
||||||
QColorDialog,
|
QColorDialog,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QInputDialog,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from bouquin.db import DBManager
|
|
||||||
from bouquin.strings import load_strings
|
|
||||||
from bouquin.tags_widget import PageTagsWidget, TagChip
|
|
||||||
from bouquin.tag_browser import TagBrowserDialog
|
|
||||||
from bouquin.flow_layout import FlowLayout
|
|
||||||
from sqlcipher3.dbapi2 import IntegrityError
|
from sqlcipher3.dbapi2 import IntegrityError
|
||||||
|
|
||||||
import bouquin.strings as strings
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DB Layer Tag Tests
|
# DB Layer Tag Tests
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db):
|
||||||
|
|
||||||
def test_flow_layout_take_at_invalid_index(app):
|
def test_flow_layout_take_at_invalid_index(app):
|
||||||
"""Test FlowLayout.takeAt with out-of-bounds index"""
|
"""Test FlowLayout.takeAt with out-of-bounds index"""
|
||||||
from PySide6.QtWidgets import QWidget, QLabel
|
from PySide6.QtWidgets import QLabel, QWidget
|
||||||
|
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = FlowLayout(widget)
|
layout = FlowLayout(widget)
|
||||||
|
|
@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app):
|
||||||
|
|
||||||
def test_flow_layout_take_at_boundary(app):
|
def test_flow_layout_take_at_boundary(app):
|
||||||
"""Test FlowLayout.takeAt at exact boundary"""
|
"""Test FlowLayout.takeAt at exact boundary"""
|
||||||
from PySide6.QtWidgets import QWidget, QLabel
|
from PySide6.QtWidgets import QLabel, QWidget
|
||||||
|
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = FlowLayout(widget)
|
layout = FlowLayout(widget)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
from PySide6.QtGui import QPalette
|
from PySide6.QtGui import QPalette
|
||||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||||
|
|
||||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_manager_apply_light_and_dark(app):
|
def test_theme_manager_apply_light_and_dark(app):
|
||||||
cfg = ThemeConfig(theme=Theme.LIGHT)
|
cfg = ThemeConfig(theme=Theme.LIGHT)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
import pytest
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from PySide6.QtCore import Qt, QDate
|
from unittest.mock import MagicMock, patch
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QMessageBox,
|
|
||||||
QInputDialog,
|
|
||||||
QFileDialog,
|
|
||||||
QDialog,
|
|
||||||
)
|
|
||||||
from sqlcipher3.dbapi2 import IntegrityError
|
|
||||||
|
|
||||||
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 (
|
from bouquin.time_log import (
|
||||||
TimeLogWidget,
|
|
||||||
TimeLogDialog,
|
|
||||||
TimeCodeManagerDialog,
|
TimeCodeManagerDialog,
|
||||||
|
TimeLogDialog,
|
||||||
|
TimeLogWidget,
|
||||||
TimeReportDialog,
|
TimeReportDialog,
|
||||||
)
|
)
|
||||||
import bouquin.strings as strings
|
from PySide6.QtCore import QDate, Qt
|
||||||
|
from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
|
||||||
from unittest.mock import patch, MagicMock
|
from sqlcipher3.dbapi2 import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -1191,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.project_combo.count() == 1
|
assert dialog.project_combo.count() == 1
|
||||||
assert dialog.granularity.count() == 3 # day, week, month
|
assert dialog.granularity.count() == 5
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
||||||
|
|
@ -1230,7 +1224,9 @@ def test_time_report_dialog_run_report(qtbot, fresh_db):
|
||||||
dialog.project_combo.setCurrentIndex(0)
|
dialog.project_combo.setCurrentIndex(0)
|
||||||
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
||||||
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
||||||
dialog.granularity.setCurrentIndex(0) # day
|
idx_day = dialog.granularity.findData("day")
|
||||||
|
assert idx_day != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_day)
|
||||||
|
|
||||||
dialog._run_report()
|
dialog._run_report()
|
||||||
|
|
||||||
|
|
@ -1417,13 +1413,18 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db):
|
||||||
dialog.project_combo.setCurrentIndex(0)
|
dialog.project_combo.setCurrentIndex(0)
|
||||||
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
||||||
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
|
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
|
||||||
dialog.granularity.setCurrentIndex(1) # week
|
|
||||||
|
idx_week = dialog.granularity.findData("week")
|
||||||
|
assert idx_week != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_week)
|
||||||
|
|
||||||
dialog._run_report()
|
dialog._run_report()
|
||||||
|
|
||||||
# Should aggregate to single week
|
# Should aggregate to single week
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
hours_text = dialog.table.item(0, 4).text()
|
# In grouped modes the Note column is hidden → hours are in column 3
|
||||||
|
hours_text = dialog.table.item(0, 3).text()
|
||||||
|
|
||||||
assert "2.5" in hours_text or "2.50" in hours_text
|
assert "2.5" in hours_text or "2.50" in hours_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1445,13 +1446,17 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db):
|
||||||
dialog.project_combo.setCurrentIndex(0)
|
dialog.project_combo.setCurrentIndex(0)
|
||||||
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
||||||
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
|
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
|
||||||
dialog.granularity.setCurrentIndex(2) # month
|
|
||||||
|
idx_month = dialog.granularity.findData("month")
|
||||||
|
assert idx_month != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_month)
|
||||||
|
|
||||||
dialog._run_report()
|
dialog._run_report()
|
||||||
|
|
||||||
# Should aggregate to single month
|
# Should aggregate to single month
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
hours_text = dialog.table.item(0, 4).text()
|
hours_text = dialog.table.item(0, 3).text()
|
||||||
|
|
||||||
assert "2.5" in hours_text or "2.50" in hours_text
|
assert "2.5" in hours_text or "2.50" in hours_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1940,7 +1945,10 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db):
|
||||||
dialog.project_combo.setCurrentIndex(1)
|
dialog.project_combo.setCurrentIndex(1)
|
||||||
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
||||||
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
|
||||||
dialog.granularity.setCurrentIndex(1) # week
|
|
||||||
|
idx_week = dialog.granularity.findData("week")
|
||||||
|
assert idx_week != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_week)
|
||||||
|
|
||||||
dialog._run_report()
|
dialog._run_report()
|
||||||
|
|
||||||
|
|
@ -1976,7 +1984,10 @@ def test_time_report_dialog_pdf_export_with_multiple_periods(
|
||||||
dialog.project_combo.setCurrentIndex(0)
|
dialog.project_combo.setCurrentIndex(0)
|
||||||
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
|
||||||
dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd"))
|
dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd"))
|
||||||
dialog.granularity.setCurrentIndex(0) # day
|
|
||||||
|
idx_day = dialog.granularity.findData("day")
|
||||||
|
assert idx_day != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_day)
|
||||||
|
|
||||||
dialog._run_report()
|
dialog._run_report()
|
||||||
|
|
||||||
|
|
@ -2933,3 +2944,69 @@ def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db):
|
||||||
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
|
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
|
||||||
qtbot.addWidget(dialog2)
|
qtbot.addWidget(dialog2)
|
||||||
assert dialog2.tabs.currentIndex() == 1
|
assert dialog2.tabs.currentIndex() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_report_no_grouping_returns_each_entry_and_note(fresh_db):
|
||||||
|
"""Granularity 'none' returns one row per entry and includes notes."""
|
||||||
|
proj_id = fresh_db.add_project("Project")
|
||||||
|
act_id = fresh_db.add_activity("Activity")
|
||||||
|
date = _today()
|
||||||
|
|
||||||
|
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
|
||||||
|
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
|
||||||
|
|
||||||
|
report = fresh_db.time_report(proj_id, date, date, "none")
|
||||||
|
|
||||||
|
# Two separate rows, not aggregated.
|
||||||
|
assert len(report) == 2
|
||||||
|
|
||||||
|
# Each row is (period, activity_name, note, total_minutes)
|
||||||
|
periods = {r[0] for r in report}
|
||||||
|
activities = {r[1] for r in report}
|
||||||
|
notes = {r[2] for r in report}
|
||||||
|
minutes = sorted(r[3] for r in report)
|
||||||
|
|
||||||
|
assert periods == {date}
|
||||||
|
assert activities == {"Activity"}
|
||||||
|
assert notes == {"First", "Second"}
|
||||||
|
assert minutes == [30, 60]
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_report_dialog_granularity_none_shows_each_entry_and_notes(
|
||||||
|
qtbot, fresh_db
|
||||||
|
):
|
||||||
|
"""'Don't group' granularity shows one row per log entry and includes notes."""
|
||||||
|
strings.load_strings("en")
|
||||||
|
proj_id = fresh_db.add_project("Project")
|
||||||
|
act_id = fresh_db.add_activity("Activity")
|
||||||
|
date = _today()
|
||||||
|
|
||||||
|
fresh_db.add_time_log(date, proj_id, act_id, 60, note="First")
|
||||||
|
fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second")
|
||||||
|
|
||||||
|
dialog = TimeReportDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
# Select the concrete project (index 0 is "All projects")
|
||||||
|
dialog.project_combo.setCurrentIndex(1)
|
||||||
|
dialog.from_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
|
||||||
|
dialog.to_date.setDate(QDate.fromString(date, "yyyy-MM-dd"))
|
||||||
|
|
||||||
|
idx_none = dialog.granularity.findData("none")
|
||||||
|
assert idx_none != -1
|
||||||
|
dialog.granularity.setCurrentIndex(idx_none)
|
||||||
|
|
||||||
|
dialog._run_report()
|
||||||
|
|
||||||
|
# Two rows, not aggregated
|
||||||
|
assert dialog.table.rowCount() == 2
|
||||||
|
|
||||||
|
# Notes in column 3
|
||||||
|
notes = {dialog.table.item(row, 3).text() for row in range(dialog.table.rowCount())}
|
||||||
|
assert "First" in notes
|
||||||
|
assert "Second" in notes
|
||||||
|
|
||||||
|
# Hours in last column (index 4) when not grouped
|
||||||
|
hours = [dialog.table.item(row, 4).text() for row in range(dialog.table.rowCount())]
|
||||||
|
assert any("1.00" in h or "1.0" in h for h in hours)
|
||||||
|
assert any("0.50" in h or "0.5" in h for h in hours)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtWidgets import QWidget
|
|
||||||
from bouquin.markdown_editor import MarkdownEditor
|
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 bouquin.toolbar import ToolBar
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from bouquin.version_check import VersionChecker
|
from bouquin.version_check import VersionChecker
|
||||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
|
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||||
|
|
||||||
|
|
||||||
def test_version_checker_init(app):
|
def test_version_checker_init(app):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue