Compare commits
59 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 | |||
| 2464147a59 | |||
| 0795de8572 | |||
| 0ec3ff273d | |||
| 304650dd54 | |||
| 9dc0a620be | |||
| 1e12cae78e | |||
| 498765c782 | |||
| 28c0dd761f | |||
| 9ded9b4a10 | |||
| 3d0f4a7787 | |||
| b06f213522 | |||
| 8823a304cf | |||
| f8909d7fcb | |||
| 779049e467 | |||
| bae91f56e6 | |||
| 3900920b7e | |||
| 3d088fd8d9 | |||
| 55aa8be2c2 | |||
| bffa615c13 | |||
| 605444b149 | |||
| 0b76f0b490 | |||
| 25f0c28582 | |||
| 422411f12e | |||
| 23b6ce62a3 | |||
| 535a380616 | |||
| 4d3593e960 | |||
| 7d58acfc7d | |||
| 22b4cf4da7 | |||
| 078f56a39b | |||
| f2bf337049 | |||
| a27b1d702a | |||
| 3aed9badc2 | |||
| 3b3087cc37 | |||
| 95b7d828b5 | |||
| 32aa1780cf | |||
| 7ed45c919c |
72 changed files with 11027 additions and 1102 deletions
|
|
@ -35,3 +35,16 @@ jobs:
|
|||
run: |
|
||||
./tests.sh
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
|
|
@ -25,3 +25,17 @@ jobs:
|
|||
pyflakes3 tests/*
|
||||
vulture
|
||||
bandit -s B110 -r bouquin/
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
|
|
@ -24,3 +24,17 @@ jobs:
|
|||
- name: Run trivy
|
||||
run: |
|
||||
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
|
||||
|
||||
# Notify if any previous step in this job failed
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||
REPOSITORY: ${{ forgejo.repository }}
|
||||
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||
SERVER_URL: ${{ forgejo.server_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||
"$WEBHOOK_URL"
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__
|
|||
dist
|
||||
.coverage
|
||||
*.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"]
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -1,3 +1,72 @@
|
|||
# 0.7.3
|
||||
|
||||
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
|
||||
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
|
||||
|
||||
# 0.7.2
|
||||
|
||||
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
|
||||
|
||||
# 0.7.1
|
||||
|
||||
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
|
||||
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
|
||||
* Invoicing should not be enabled by default
|
||||
* Fix Reminders to fire right on the minute after adding them during runtime
|
||||
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
|
||||
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
|
||||
|
||||
# 0.7.0
|
||||
|
||||
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.
|
||||
* Add 'Last week' to Time Report dialog range option
|
||||
* Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs)
|
||||
|
||||
# 0.6.4
|
||||
|
||||
* Time reports: Fix report 'group by' logic to not show ambiguous 'note' data.
|
||||
* Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case)
|
||||
* Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date
|
||||
|
||||
# 0.6.3
|
||||
|
||||
* Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month.
|
||||
* Allow 'All Projects' for timesheet reports.
|
||||
* Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed)
|
||||
* Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday)
|
||||
|
||||
# 0.6.2
|
||||
|
||||
* Ensure that adding a document whilst on an older date page, uses that date as its upload date
|
||||
* Add 'Created at' to time log table.
|
||||
* Show total hours for the day in the time log table (not just in the widget in sidebar)
|
||||
* Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
|
||||
* Indent tabs by 4 spaces in code block editor dialog
|
||||
|
||||
# 0.6.1
|
||||
|
||||
* Consolidate some code related to opening documents using the Documents feature.
|
||||
* Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time.
|
||||
* More code coverage
|
||||
|
||||
# 0.6.0
|
||||
|
||||
* Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.
|
||||
* Close time log dialog if opened via the + button from sidebar widget
|
||||
* Only show tags in Statistics widget if tags are enabled
|
||||
* Fix rounding up/down in Pomodoro timer to the closest 15 min interval
|
||||
|
||||
# 0.5.5
|
||||
|
||||
* Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option)
|
||||
* Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text.
|
||||
* Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager)
|
||||
* Ensure time log reports have an extension
|
||||
|
||||
# 0.5.4
|
||||
|
||||
* Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it)
|
||||
|
||||
# 0.5.3
|
||||
|
||||
* Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
|
||||
|
|
|
|||
|
|
@ -72,14 +72,13 @@ report from within the app, or optionally to check for new versions to upgrade t
|
|||
* English, French and Italian locales provided
|
||||
* Ability to set reminder alarms (which will be flashed as the reminder)
|
||||
* Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
|
||||
* Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
|
||||
|
||||
|
||||
## How to install
|
||||
|
||||
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||
|
||||
It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes.
|
||||
|
||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||
|
||||
### From PyPi/pip
|
||||
|
|
|
|||
|
|
@ -3,19 +3,17 @@ from __future__ import annotations
|
|||
import importlib.metadata
|
||||
|
||||
import requests
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QTextEdit,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
|
||||
BUG_REPORT_HOST = "https://nr.mig5.net"
|
||||
ROUTE = "forms/bouquin/bugs"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, QRect, Qt
|
||||
from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QPlainTextEdit,
|
||||
QDialogButtonBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
|
@ -40,9 +39,21 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
|
|||
self.cursorPositionChanged.connect(self._line_number_area.update)
|
||||
|
||||
self._update_line_number_area_width()
|
||||
self._update_tab_stop_width()
|
||||
|
||||
# ---- layout / sizing -------------------------------------------------
|
||||
|
||||
def setFont(self, font: QFont) -> None: # type: ignore[override]
|
||||
"""Ensure tab width stays at 4 spaces when the font changes."""
|
||||
super().setFont(font)
|
||||
self._update_tab_stop_width()
|
||||
|
||||
def _update_tab_stop_width(self) -> None:
|
||||
"""Set tab width to 4 spaces."""
|
||||
metrics = QFontMetrics(self.font())
|
||||
# Tab width = width of 4 space characters
|
||||
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
|
||||
|
||||
def line_number_area_width(self) -> int:
|
||||
# Enough digits for large-ish code blocks.
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtGui import QColor, QTextCharFormat, QFont
|
||||
from PySide6.QtGui import QColor, QFont, QTextCharFormat
|
||||
|
||||
|
||||
class CodeHighlighter:
|
||||
|
|
|
|||
1343
bouquin/db.py
1343
bouquin/db.py
File diff suppressed because it is too large
Load diff
64
bouquin/document_utils.py
Normal file
64
bouquin/document_utils.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Utility functions for document operations.
|
||||
|
||||
This module provides shared functionality for document handling across
|
||||
different widgets (TodaysDocumentsWidget, DocumentsDialog, SearchResultsDialog,
|
||||
and TagBrowserDialog).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from . import strings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .db import DBManager
|
||||
|
||||
|
||||
def open_document_from_db(
|
||||
db: DBManager, doc_id: int, file_name: str, parent_widget: Optional[QWidget] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Open a document by fetching it from the database and opening with system default app.
|
||||
"""
|
||||
# Fetch document data from database
|
||||
try:
|
||||
data = db.document_data(doc_id)
|
||||
except Exception as e:
|
||||
# Show error dialog if parent widget is provided
|
||||
if parent_widget:
|
||||
QMessageBox.warning(
|
||||
parent_widget,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_open_failed").format(error=str(e)),
|
||||
)
|
||||
return False
|
||||
|
||||
# Extract file extension
|
||||
suffix = Path(file_name).suffix or ""
|
||||
|
||||
# Create temporary file with same extension
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
prefix="bouquin_doc_",
|
||||
suffix=suffix,
|
||||
delete=False,
|
||||
)
|
||||
|
||||
# Write data to temp file
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
# Open with system default application
|
||||
success = QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
|
||||
|
||||
return success
|
||||
601
bouquin/documents.py
Normal file
601
bouquin/documents.py
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager, DocumentRow
|
||||
from .settings import load_db_config
|
||||
from .time_log import TimeCodeManagerDialog
|
||||
|
||||
|
||||
class TodaysDocumentsWidget(QFrame):
|
||||
"""
|
||||
Collapsible sidebar widget showing today's documents.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db: DBManager, date_iso: str, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._current_date = date_iso
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# Header (toggle + open-documents button)
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText(strings._("todays_documents"))
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
self.toggle_btn.setArrowType(Qt.RightArrow)
|
||||
self.toggle_btn.clicked.connect(self._on_toggle)
|
||||
|
||||
self.open_btn = QToolButton()
|
||||
self.open_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.open_btn.setToolTip(strings._("project_documents_title"))
|
||||
self.open_btn.setAutoRaise(True)
|
||||
self.open_btn.clicked.connect(self._open_documents_dialog)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.toggle_btn)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.open_btn)
|
||||
|
||||
# Body: list of today's documents
|
||||
self.body = QWidget()
|
||||
body_layout = QVBoxLayout(self.body)
|
||||
body_layout.setContentsMargins(0, 4, 0, 0)
|
||||
body_layout.setSpacing(2)
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.list.setMaximumHeight(160)
|
||||
self.list.itemDoubleClicked.connect(self._open_selected_document)
|
||||
body_layout.addWidget(self.list)
|
||||
|
||||
self.body.setVisible(False)
|
||||
|
||||
main = QVBoxLayout(self)
|
||||
main.setContentsMargins(0, 0, 0, 0)
|
||||
main.addLayout(header)
|
||||
main.addWidget(self.body)
|
||||
|
||||
# Initial fill
|
||||
self.reload()
|
||||
|
||||
# ----- public API ---------------------------------------------------
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Refresh the list of today's documents."""
|
||||
self.list.clear()
|
||||
|
||||
rows = self._db.todays_documents(self._current_date)
|
||||
if not rows:
|
||||
item = QListWidgetItem(strings._("todays_documents_none"))
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
|
||||
self.list.addItem(item)
|
||||
return
|
||||
|
||||
for doc_id, file_name, project_name in rows:
|
||||
label = file_name
|
||||
extra_parts = []
|
||||
if project_name:
|
||||
extra_parts.append(project_name)
|
||||
if extra_parts:
|
||||
label = f"{file_name} - " + " · ".join(extra_parts)
|
||||
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"doc_id": doc_id, "file_name": file_name},
|
||||
)
|
||||
self.list.addItem(item)
|
||||
|
||||
# ----- internals ----------------------------------------------------
|
||||
|
||||
def set_current_date(self, date_iso: str) -> None:
|
||||
self._current_date = date_iso
|
||||
self.reload()
|
||||
|
||||
def _on_toggle(self, checked: bool) -> None:
|
||||
self.body.setVisible(checked)
|
||||
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
if checked:
|
||||
self.reload()
|
||||
|
||||
def _open_selected_document(self, item: QListWidgetItem) -> None:
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
doc_id = data.get("doc_id")
|
||||
file_name = data.get("file_name") or ""
|
||||
if doc_id is None or not file_name:
|
||||
return
|
||||
self._open_document(int(doc_id), file_name)
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open a document from the list."""
|
||||
from .document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _open_documents_dialog(self) -> None:
|
||||
"""Open the full DocumentsDialog."""
|
||||
dlg = DocumentsDialog(self._db, self, current_date=self._current_date)
|
||||
dlg.exec()
|
||||
# Refresh after any changes
|
||||
self.reload()
|
||||
|
||||
|
||||
class DocumentsDialog(QDialog):
|
||||
"""
|
||||
Per-project document manager.
|
||||
|
||||
- Choose a project
|
||||
- See list of attached documents
|
||||
- Add (from file), open (via temp file), delete
|
||||
- Inline-edit description
|
||||
- Inline-edit tags (comma-separated), using the global tags table
|
||||
"""
|
||||
|
||||
FILE_COL = 0
|
||||
TAGS_COL = 1
|
||||
DESC_COL = 2
|
||||
ADDED_COL = 3
|
||||
SIZE_COL = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: DBManager,
|
||||
parent: QWidget | None = None,
|
||||
initial_project_id: Optional[int] = None,
|
||||
current_date: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self.cfg = load_db_config()
|
||||
self._reloading_docs = False
|
||||
self._search_text: str = ""
|
||||
self._current_date = current_date # Store the current date for document uploads
|
||||
|
||||
self.setWindowTitle(strings._("project_documents_title"))
|
||||
self.resize(900, 450)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# --- Project selector -------------------------------------------------
|
||||
form = QFormLayout()
|
||||
proj_row = QHBoxLayout()
|
||||
self.project_combo = QComboBox()
|
||||
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
|
||||
self.manage_projects_btn.clicked.connect(self._manage_projects)
|
||||
proj_row.addWidget(self.project_combo, 1)
|
||||
proj_row.addWidget(self.manage_projects_btn)
|
||||
form.addRow(strings._("project"), proj_row)
|
||||
|
||||
# --- Search box (all projects) ----------------------------------------
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setClearButtonEnabled(True)
|
||||
self.search_edit.setPlaceholderText(strings._("documents_search_placeholder"))
|
||||
self.search_edit.textChanged.connect(self._on_search_text_changed)
|
||||
form.addRow(strings._("documents_search_label"), self.search_edit)
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||
|
||||
# --- Table of documents ----------------------------------------------
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[
|
||||
strings._("documents_col_file"), # FILE_COL
|
||||
strings._("documents_col_tags"), # TAGS_COL
|
||||
strings._("documents_col_description"), # DESC_COL
|
||||
strings._("documents_col_added"), # ADDED_COL
|
||||
strings._("documents_col_size"), # SIZE_COL
|
||||
]
|
||||
)
|
||||
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(self.FILE_COL, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(self.TAGS_COL, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(self.DESC_COL, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(self.ADDED_COL, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(self.SIZE_COL, QHeaderView.ResizeToContents)
|
||||
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
# Editable: tags + description
|
||||
self.table.setEditTriggers(
|
||||
QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked
|
||||
)
|
||||
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.itemDoubleClicked.connect(self._on_open_clicked)
|
||||
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
# --- Buttons ---------------------------------------------------------
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch(1)
|
||||
|
||||
self.add_btn = QPushButton(strings._("documents_add"))
|
||||
self.add_btn.clicked.connect(self._on_add_clicked)
|
||||
btn_row.addWidget(self.add_btn)
|
||||
|
||||
self.open_btn = QPushButton(strings._("documents_open"))
|
||||
self.open_btn.clicked.connect(self._on_open_clicked)
|
||||
btn_row.addWidget(self.open_btn)
|
||||
|
||||
self.delete_btn = QPushButton(strings._("documents_delete"))
|
||||
self.delete_btn.clicked.connect(self._on_delete_clicked)
|
||||
btn_row.addWidget(self.delete_btn)
|
||||
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# Separator at bottom (purely cosmetic)
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
root.addWidget(line)
|
||||
|
||||
# Init data
|
||||
self._reload_projects()
|
||||
self._select_initial_project(initial_project_id)
|
||||
self._reload_documents()
|
||||
|
||||
# --- Helpers -------------------------------------------------------------
|
||||
|
||||
def _reload_projects(self) -> None:
|
||||
self.project_combo.blockSignals(True)
|
||||
try:
|
||||
self.project_combo.clear()
|
||||
for proj_id, name in self._db.list_projects():
|
||||
self.project_combo.addItem(name, proj_id)
|
||||
finally:
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def _select_initial_project(self, project_id: Optional[int]) -> None:
|
||||
if project_id is None:
|
||||
if self.project_combo.count() > 0:
|
||||
self.project_combo.setCurrentIndex(0)
|
||||
return
|
||||
|
||||
idx = self.project_combo.findData(project_id)
|
||||
if idx >= 0:
|
||||
self.project_combo.setCurrentIndex(idx)
|
||||
elif self.project_combo.count() > 0:
|
||||
self.project_combo.setCurrentIndex(0)
|
||||
|
||||
def _current_project(self) -> Optional[int]:
|
||||
idx = self.project_combo.currentIndex()
|
||||
if idx < 0:
|
||||
return None
|
||||
proj_id = self.project_combo.itemData(idx)
|
||||
return int(proj_id) if proj_id is not None else None
|
||||
|
||||
def _manage_projects(self) -> None:
|
||||
dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self)
|
||||
dlg.exec()
|
||||
self._reload_projects()
|
||||
self._reload_documents()
|
||||
|
||||
def _on_search_text_changed(self, text: str) -> None:
|
||||
"""Update the in-memory search text and reload the table."""
|
||||
self._search_text = text
|
||||
self._reload_documents()
|
||||
|
||||
def _reload_documents(self) -> None:
|
||||
|
||||
search = (self._search_text or "").strip()
|
||||
|
||||
self._reloading_docs = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
|
||||
if search:
|
||||
# Global search across all projects
|
||||
rows: list[DocumentRow] = self._db.search_documents(search)
|
||||
|
||||
else:
|
||||
proj_id = self._current_project()
|
||||
if proj_id is None:
|
||||
return
|
||||
|
||||
rows = self._db.documents_for_project(proj_id)
|
||||
|
||||
self.table.setRowCount(len(rows))
|
||||
|
||||
for row_idx, r in enumerate(rows):
|
||||
(
|
||||
doc_id,
|
||||
_project_id,
|
||||
project_name,
|
||||
file_name,
|
||||
description,
|
||||
size_bytes,
|
||||
uploaded_at,
|
||||
) = r
|
||||
|
||||
# Col 0: File
|
||||
file_item = QTableWidgetItem(file_name)
|
||||
file_item.setData(Qt.ItemDataRole.UserRole, doc_id)
|
||||
file_item.setFlags(file_item.flags() & ~Qt.ItemIsEditable)
|
||||
self.table.setItem(row_idx, self.FILE_COL, file_item)
|
||||
|
||||
# Col 1: Tags (comma-separated)
|
||||
tags = self._db.get_tags_for_document(doc_id)
|
||||
tag_names = [name for (_tid, name, _color) in tags]
|
||||
tags_text = ", ".join(tag_names)
|
||||
tags_item = QTableWidgetItem(tags_text)
|
||||
|
||||
# If there is at least one tag, colour the cell using the first tag's colour
|
||||
if tags:
|
||||
first_color = tags[0][2]
|
||||
if first_color:
|
||||
col = QColor(first_color)
|
||||
tags_item.setBackground(col)
|
||||
# Choose a readable text color
|
||||
if col.lightness() < 128:
|
||||
tags_item.setForeground(QColor("#ffffff"))
|
||||
else:
|
||||
tags_item.setForeground(QColor("#000000"))
|
||||
|
||||
self.table.setItem(row_idx, self.TAGS_COL, tags_item)
|
||||
if not self.cfg.tags:
|
||||
self.table.hideColumn(self.TAGS_COL)
|
||||
|
||||
# Col 2: Description (editable)
|
||||
desc_item = QTableWidgetItem(description or "")
|
||||
self.table.setItem(row_idx, self.DESC_COL, desc_item)
|
||||
|
||||
# Col 3: Added at (editable)
|
||||
added_label = uploaded_at
|
||||
added_item = QTableWidgetItem(added_label)
|
||||
self.table.setItem(row_idx, self.ADDED_COL, added_item)
|
||||
|
||||
# Col 4: Size (not editable)
|
||||
size_item = QTableWidgetItem(self._format_size(size_bytes))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemIsEditable)
|
||||
self.table.setItem(row_idx, self.SIZE_COL, size_item)
|
||||
finally:
|
||||
self._reloading_docs = False
|
||||
|
||||
# --- Signals -------------------------------------------------------------
|
||||
|
||||
def _on_project_changed(self, idx: int) -> None:
|
||||
_ = idx
|
||||
self._reload_documents()
|
||||
|
||||
def _on_add_clicked(self) -> None:
|
||||
proj_id = self._current_project()
|
||||
if proj_id is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_no_project_selected"),
|
||||
)
|
||||
return
|
||||
|
||||
paths, _ = QFileDialog.getOpenFileNames(
|
||||
self,
|
||||
strings._("documents_add"),
|
||||
"",
|
||||
strings._("documents_file_filter_all"),
|
||||
)
|
||||
if not paths:
|
||||
return
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
self._db.add_document_from_path(
|
||||
proj_id, path, uploaded_at=self._current_date
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_add_failed").format(error=str(e)),
|
||||
)
|
||||
|
||||
self._reload_documents()
|
||||
|
||||
def _selected_doc_meta(self) -> tuple[Optional[int], Optional[str]]:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
return None, None
|
||||
|
||||
file_item = self.table.item(row, self.FILE_COL)
|
||||
if file_item is None:
|
||||
return None, None
|
||||
|
||||
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
|
||||
file_name = file_item.text()
|
||||
return (int(doc_id) if doc_id is not None else None, file_name)
|
||||
|
||||
def _on_open_clicked(self, *args) -> None:
|
||||
doc_id, file_name = self._selected_doc_meta()
|
||||
if doc_id is None or not file_name:
|
||||
return
|
||||
self._open_document(doc_id, file_name)
|
||||
|
||||
def _on_delete_clicked(self) -> None:
|
||||
doc_id, _file_name = self._selected_doc_meta()
|
||||
if doc_id is None:
|
||||
return
|
||||
|
||||
resp = QMessageBox.question(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
strings._("documents_confirm_delete"),
|
||||
)
|
||||
if resp != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._db.delete_document(doc_id)
|
||||
self._reload_documents()
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
"""
|
||||
Handle inline edits to Description, Tags, and Added date.
|
||||
"""
|
||||
if self._reloading_docs or item is None:
|
||||
return
|
||||
|
||||
row = item.row()
|
||||
col = item.column()
|
||||
|
||||
file_item = self.table.item(row, self.FILE_COL)
|
||||
if file_item is None:
|
||||
return
|
||||
|
||||
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
|
||||
if doc_id is None:
|
||||
return
|
||||
|
||||
doc_id = int(doc_id)
|
||||
|
||||
# Description column
|
||||
if col == self.DESC_COL:
|
||||
desc = item.text().strip() or None
|
||||
self._db.update_document_description(doc_id, desc)
|
||||
return
|
||||
|
||||
# Tags column
|
||||
if col == self.TAGS_COL:
|
||||
raw = item.text()
|
||||
# split on commas, strip, drop empties
|
||||
names = [p.strip() for p in raw.split(",") if p.strip()]
|
||||
self._db.set_tags_for_document(doc_id, names)
|
||||
|
||||
# Re-normalise text to the canonical tag names stored in DB
|
||||
tags = self._db.get_tags_for_document(doc_id)
|
||||
tag_names = [name for (_tid, name, _color) in tags]
|
||||
tags_text = ", ".join(tag_names)
|
||||
|
||||
self._reloading_docs = True
|
||||
try:
|
||||
item.setText(tags_text)
|
||||
# Reset / apply background based on first tag colour
|
||||
if tags:
|
||||
first_color = tags[0][2]
|
||||
if first_color:
|
||||
col = QColor(first_color)
|
||||
item.setBackground(col)
|
||||
if col.lightness() < 128:
|
||||
item.setForeground(QColor("#ffffff"))
|
||||
else:
|
||||
item.setForeground(QColor("#000000"))
|
||||
else:
|
||||
# No tags: clear background / foreground to defaults
|
||||
item.setBackground(QColor())
|
||||
item.setForeground(QColor())
|
||||
finally:
|
||||
self._reloading_docs = False
|
||||
return
|
||||
|
||||
# Added date column
|
||||
if col == self.ADDED_COL:
|
||||
date_str = item.text().strip()
|
||||
|
||||
# Validate date format (YYYY-MM-DD)
|
||||
if not self._validate_date_format(date_str):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
strings._("project_documents_title"),
|
||||
(
|
||||
strings._("documents_invalid_date_format")
|
||||
if hasattr(strings, "_")
|
||||
and callable(getattr(strings, "_"))
|
||||
and "documents_invalid_date_format" in dir(strings)
|
||||
else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15"
|
||||
),
|
||||
)
|
||||
# Reload to reset the cell to its original value
|
||||
self._reload_documents()
|
||||
return
|
||||
|
||||
# Update the database
|
||||
self._db.update_document_uploaded_at(doc_id, date_str)
|
||||
return
|
||||
|
||||
# --- utils -------------------------------------------------------------
|
||||
|
||||
def _validate_date_format(self, date_str: str) -> bool:
|
||||
"""
|
||||
Validate that a date string is in YYYY-MM-DD format.
|
||||
|
||||
Returns True if valid, False otherwise.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# Check basic format with regex
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
|
||||
return False
|
||||
|
||||
# Validate it's a real date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""
|
||||
Fetch BLOB from DB, write to a temporary file, and open with default app.
|
||||
"""
|
||||
from .document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Human-readable file size.
|
||||
"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
kb = size_bytes / 1024.0
|
||||
if kb < 1024:
|
||||
return f"{kb:.1f} KB"
|
||||
mb = kb / 1024.0
|
||||
if mb < 1024:
|
||||
return f"{mb:.1f} MB"
|
||||
gb = mb / 1024.0
|
||||
return f"{gb:.1f} GB"
|
||||
|
|
@ -1,20 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QShortcut,
|
||||
QTextCursor,
|
||||
QTextCharFormat,
|
||||
QTextDocument,
|
||||
)
|
||||
from PySide6.QtGui import QShortcut, QTextCharFormat, QTextCursor, QTextDocument
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ Fonts, only if the fonts are renamed to names not containing either
|
|||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import difflib, re, html as _html
|
||||
import difflib
|
||||
import html as _html
|
||||
import re
|
||||
from datetime import datetime
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCalendarWidget,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QMessageBox,
|
||||
QTextBrowser,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QAbstractItemView,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
def _markdown_to_text(s: str) -> str:
|
||||
|
|
@ -70,16 +77,29 @@ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
|||
class HistoryDialog(QDialog):
|
||||
"""Show versions for a date, preview, diff, and allow revert."""
|
||||
|
||||
def __init__(self, db, date_iso: str, parent=None):
|
||||
def __init__(
|
||||
self, db, date_iso: str, parent=None, themes: ThemeManager | None = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"{strings._('history')} — {date_iso}")
|
||||
self._db = db
|
||||
self._date = date_iso
|
||||
self._themes = themes
|
||||
self._versions = [] # list[dict] from DB
|
||||
self._current_id = None # id of current
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# --- Top: date label + change-date button
|
||||
date_row = QHBoxLayout()
|
||||
self.date_label = QLabel(strings._("date_label").format(date=date_iso))
|
||||
date_row.addWidget(self.date_label)
|
||||
date_row.addStretch(1)
|
||||
self.change_date_btn = QPushButton(strings._("change_date"))
|
||||
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
|
||||
date_row.addWidget(self.change_date_btn)
|
||||
root.addLayout(date_row)
|
||||
|
||||
# Top: list of versions
|
||||
top = QHBoxLayout()
|
||||
self.list = QListWidget()
|
||||
|
|
@ -117,6 +137,53 @@ class HistoryDialog(QDialog):
|
|||
|
||||
self._load_versions()
|
||||
|
||||
@Slot()
|
||||
def _on_change_date_clicked(self) -> None:
|
||||
"""Let the user choose a different date and reload entries."""
|
||||
|
||||
# Start from current dialog date; fall back to today if invalid
|
||||
current_qdate = QDate.fromString(self._date, Qt.ISODate)
|
||||
if not current_qdate.isValid():
|
||||
current_qdate = QDate.currentDate()
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle(strings._("select_date_title"))
|
||||
|
||||
layout = QVBoxLayout(dlg)
|
||||
|
||||
calendar = QCalendarWidget(dlg)
|
||||
calendar.setSelectedDate(current_qdate)
|
||||
layout.addWidget(calendar)
|
||||
# Apply the same theming as the main sidebar calendar
|
||||
if self._themes is not None:
|
||||
self._themes.register_calendar(calendar)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
|
||||
)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return
|
||||
|
||||
new_qdate = calendar.selectedDate()
|
||||
new_iso = new_qdate.toString(Qt.ISODate)
|
||||
if new_iso == self._date:
|
||||
# No change
|
||||
return
|
||||
|
||||
# Update state
|
||||
self._date = new_iso
|
||||
|
||||
# Update window title and header label
|
||||
self.setWindowTitle(strings._("for").format(date=new_iso))
|
||||
self.date_label.setText(strings._("date_label").format(date=new_iso))
|
||||
|
||||
# Reload entries for the newly selected date
|
||||
self._load_versions()
|
||||
|
||||
# --- Data/UX helpers ---
|
||||
def _load_versions(self):
|
||||
# [{id,version_no,created_at,note,is_current}]
|
||||
|
|
|
|||
1445
bouquin/invoices.py
Normal file
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 (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
"next_day": "Next day",
|
||||
"today": "Today",
|
||||
"show": "Show",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"history": "History",
|
||||
"export_accessible_flag": "&Export",
|
||||
"export_entries": "Export entries",
|
||||
|
|
@ -101,6 +103,7 @@
|
|||
"autosave": "autosave",
|
||||
"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_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
|
||||
"insert_images": "Insert images",
|
||||
"images": "Images",
|
||||
"reopen_failed": "Re-open failed",
|
||||
|
|
@ -142,6 +145,7 @@
|
|||
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
|
||||
"color_hex": "Colour",
|
||||
"date": "Date",
|
||||
"page_or_document": "Page / Document",
|
||||
"add_a_tag": "Add a tag",
|
||||
"edit_tag_name": "Edit tag name",
|
||||
"new_tag_name": "New tag name:",
|
||||
|
|
@ -151,6 +155,11 @@
|
|||
"tag_already_exists_with_that_name": "A tag already exists with that name",
|
||||
"statistics": "Statistics",
|
||||
"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_total_revisions": "Total revisions",
|
||||
"stats_page_most_revisions": "Page with most revisions",
|
||||
|
|
@ -161,7 +170,18 @@
|
|||
"stats_heatmap_metric": "Colour by",
|
||||
"stats_metric_words": "Words",
|
||||
"stats_metric_revisions": "Revisions",
|
||||
"stats_metric_documents": "Documents",
|
||||
"stats_total_documents": "Total documents",
|
||||
"stats_date_most_documents": "Date with most documents",
|
||||
"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",
|
||||
"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",
|
||||
|
|
@ -189,10 +209,18 @@
|
|||
"add_project": "Add project",
|
||||
"add_time_entry": "Add time entry",
|
||||
"time_period": "Time period",
|
||||
"dont_group": "Don't group",
|
||||
"by_activity": "by activity",
|
||||
"by_day": "by day",
|
||||
"by_month": "by month",
|
||||
"by_week": "by week",
|
||||
"date_range": "Date range",
|
||||
"custom_range": "Custom",
|
||||
"last_week": "Last week",
|
||||
"this_week": "This week",
|
||||
"this_month": "This month",
|
||||
"this_year": "This year",
|
||||
"all_projects": "All projects",
|
||||
"delete_activity": "Delete activity",
|
||||
"delete_activity_confirm": "Are you sure you want to delete this activity?",
|
||||
"delete_activity_title": "Delete activity - are you sure?",
|
||||
|
|
@ -202,6 +230,7 @@
|
|||
"delete_time_entry": "Delete time entry",
|
||||
"group_by": "Group by",
|
||||
"hours": "Hours",
|
||||
"created_at": "Created at",
|
||||
"invalid_activity_message": "The activity is invalid",
|
||||
"invalid_activity_title": "Invalid activity",
|
||||
"invalid_project_message": "The project is invalid",
|
||||
|
|
@ -220,6 +249,8 @@
|
|||
"projects": "Projects",
|
||||
"rename_activity": "Rename activity",
|
||||
"rename_project": "Rename project",
|
||||
"reporting": "Reporting",
|
||||
"reporting_and_invoicing": "Reporting and Invoicing",
|
||||
"run_report": "Run report",
|
||||
"add_activity_title": "Add activity",
|
||||
"add_activity_label": "Add an activity",
|
||||
|
|
@ -235,8 +266,10 @@
|
|||
"select_project_title": "Select project",
|
||||
"time_log": "Time log",
|
||||
"time_log_collapsed_hint": "Time log",
|
||||
"time_log_date_label": "Time log date: {date}",
|
||||
"time_log_for": "Time log for {date}",
|
||||
"date_label": "Date: {date}",
|
||||
"change_date": "Change date",
|
||||
"select_date_title": "Select date",
|
||||
"for": "For {date}",
|
||||
"time_log_no_date": "Time log",
|
||||
"time_log_no_entries": "No time entries yet",
|
||||
"time_log_report": "Time log report",
|
||||
|
|
@ -259,6 +292,10 @@
|
|||
"enable_tags_feature": "Enable Tags",
|
||||
"enable_time_log_feature": "Enable Time Logging",
|
||||
"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",
|
||||
"pomodoro_time_log_default_text": "Focus session",
|
||||
"toolbar_pomodoro_timer": "Time-logging timer",
|
||||
"set_code_language": "Set code language",
|
||||
|
|
@ -269,18 +306,35 @@
|
|||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop_and_log": "Stop and log",
|
||||
"manage_reminders": "Manage Reminders",
|
||||
"upcoming_reminders": "Upcoming Reminders",
|
||||
"no_upcoming_reminders": "No upcoming reminders",
|
||||
"once": "once",
|
||||
"daily": "daily",
|
||||
"weekdays": "weekdays",
|
||||
"weekly": "weekly",
|
||||
"set_reminder": "Set reminder",
|
||||
"edit_reminder": "Edit reminder",
|
||||
"add_reminder": "Add Reminder",
|
||||
"set_reminder": "Set Reminder",
|
||||
"edit_reminder": "Edit Reminder",
|
||||
"delete_reminder": "Delete Reminder",
|
||||
"delete_reminders": "Delete Reminders",
|
||||
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
||||
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
||||
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
||||
"reminder": "Reminder",
|
||||
"reminders": "Reminders",
|
||||
"time": "Time",
|
||||
"once_today": "Once (today)",
|
||||
"once": "Once",
|
||||
"every_day": "Every day",
|
||||
"every_weekday": "Every weekday (Mon-Fri)",
|
||||
"every_week": "Every week",
|
||||
"every_fortnight": "Every 2 weeks",
|
||||
"every_month": "Every month (same date)",
|
||||
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
|
||||
"week_in_month": "Week in month",
|
||||
"fortnightly": "Fortnightly",
|
||||
"monthly_same_date": "Monthly (same date)",
|
||||
"monthly_nth_weekday": "Monthly (nth weekday)",
|
||||
"repeat": "Repeat",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
|
|
@ -289,7 +343,90 @@
|
|||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"monday_short": "Mon",
|
||||
"tuesday_short": "Tue",
|
||||
"wednesday_short": "Wed",
|
||||
"thursday_short": "Thu",
|
||||
"friday_short": "Fri",
|
||||
"saturday_short": "Sat",
|
||||
"sunday_short": "Sun",
|
||||
"day": "Day",
|
||||
"text": "Text",
|
||||
"type": "Type",
|
||||
"active": "Active",
|
||||
"actions": "Actions",
|
||||
"edit_code_block": "Edit code block",
|
||||
"delete_code_block": "Delete code block"
|
||||
"delete_code_block": "Delete code block",
|
||||
"search_result_heading_document": "Document",
|
||||
"toolbar_documents": "Documents Manager",
|
||||
"project_documents_title": "Project documents",
|
||||
"documents_col_file": "File",
|
||||
"documents_col_description": "Description",
|
||||
"documents_col_added": "Added",
|
||||
"documents_col_path": "Path",
|
||||
"documents_col_tags": "Tags",
|
||||
"documents_col_size": "Size",
|
||||
"documents_add": "&Add",
|
||||
"documents_add_document": "Add a document",
|
||||
"documents_open": "&Open",
|
||||
"documents_delete": "&Delete",
|
||||
"documents_no_project_selected": "Please choose a project first.",
|
||||
"documents_file_filter_all": "All files (*)",
|
||||
"documents_add_failed": "Could not add document: {error}",
|
||||
"documents_open_failed": "Could not open document: {error}",
|
||||
"documents_missing_file": "The file does not exist:\n{path}",
|
||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||
"documents_search_label": "Search",
|
||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||
"todays_documents": "Documents from this day",
|
||||
"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",
|
||||
"edit_reminder": "Modifier le rappel",
|
||||
"time": "Heure",
|
||||
"once_today": "Une fois (aujourd'hui)",
|
||||
"once": "Une fois (aujourd'hui)",
|
||||
"every_day": "Tous les jours",
|
||||
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
||||
"every_week": "Toutes les semaines",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from . import strings
|
||||
from .theme import ThemeManager
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ from __future__ import annotations
|
|||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from .settings import APP_NAME, APP_ORG, get_settings
|
||||
from .main_window import MainWindow
|
||||
from .theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import strings
|
||||
from .main_window import MainWindow
|
||||
from .settings import APP_NAME, APP_ORG, get_settings
|
||||
from .theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@ from __future__ import annotations
|
|||
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QDate,
|
||||
QTimer,
|
||||
Qt,
|
||||
QSettings,
|
||||
Slot,
|
||||
QUrl,
|
||||
QEvent,
|
||||
QSignalBlocker,
|
||||
QDateTime,
|
||||
QEvent,
|
||||
QSettings,
|
||||
QSignalBlocker,
|
||||
Qt,
|
||||
QTime,
|
||||
QTimer,
|
||||
QUrl,
|
||||
Slot,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
|
|
@ -27,43 +27,43 @@ from PySide6.QtGui import (
|
|||
QFont,
|
||||
QGuiApplication,
|
||||
QKeySequence,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
QTextListFormat,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCalendarWidget,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTableView,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QApplication,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .bug_report_dialog import BugReportDialog
|
||||
from .db import DBManager
|
||||
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
||||
from .find_bar import FindBar
|
||||
from .history_dialog import HistoryDialog
|
||||
from .key_prompt import KeyPrompt
|
||||
from .lock_overlay import LockOverlay
|
||||
from .markdown_editor import MarkdownEditor
|
||||
from .pomodoro_timer import PomodoroManager
|
||||
from .reminders import UpcomingRemindersWidget
|
||||
from .reminders import UpcomingRemindersWidget, ReminderWebHook
|
||||
from .save_dialog import SaveDialog
|
||||
from .search import Search
|
||||
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
|
||||
from .settings_dialog import SettingsDialog
|
||||
from .statistics_dialog import StatisticsDialog
|
||||
from . import strings
|
||||
from .tags_widget import PageTagsWidget
|
||||
from .theme import ThemeManager
|
||||
from .time_log import TimeLogWidget
|
||||
|
|
@ -108,15 +108,19 @@ class MainWindow(QMainWindow):
|
|||
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
||||
|
||||
# Features
|
||||
self.time_log = TimeLogWidget(self.db)
|
||||
self.time_log = TimeLogWidget(self.db, themes=self.themes)
|
||||
|
||||
self.tags = PageTagsWidget(self.db)
|
||||
self.tags.tagActivated.connect(self._on_tag_activated)
|
||||
self.tags.tagAdded.connect(self._on_tag_added)
|
||||
|
||||
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
||||
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
|
||||
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
||||
|
||||
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
|
||||
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
|
||||
|
||||
self.pomodoro_manager = PomodoroManager(self.db, self)
|
||||
|
||||
# Lock the calendar to the left panel at the top to stop it stretching
|
||||
|
|
@ -127,6 +131,8 @@ class MainWindow(QMainWindow):
|
|||
left_layout.addWidget(self.calendar)
|
||||
left_layout.addWidget(self.search)
|
||||
left_layout.addWidget(self.upcoming_reminders)
|
||||
self.todays_documents = TodaysDocumentsWidget(self.db, self._current_date_iso())
|
||||
left_layout.addWidget(self.todays_documents)
|
||||
left_layout.addWidget(self.time_log)
|
||||
left_layout.addWidget(self.tags)
|
||||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||
|
|
@ -336,13 +342,15 @@ class MainWindow(QMainWindow):
|
|||
if not self.cfg.reminders:
|
||||
self.upcoming_reminders.hide()
|
||||
self.toolBar.actAlarm.setVisible(False)
|
||||
if not self.cfg.documents:
|
||||
self.todays_documents.hide()
|
||||
self.toolBar.actDocuments.setVisible(False)
|
||||
|
||||
# Restore window position from settings
|
||||
self._restore_window_position()
|
||||
|
||||
# re-apply all runtime color tweaks when theme changes
|
||||
self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
|
||||
self._apply_calendar_text_colors()
|
||||
|
||||
# apply once on startup so links / calendar colors are set immediately
|
||||
self._retheme_overrides()
|
||||
|
|
@ -489,7 +497,7 @@ class MainWindow(QMainWindow):
|
|||
idx = self._tab_index_for_date(date)
|
||||
if idx != -1:
|
||||
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
|
||||
|
||||
with QSignalBlocker(self.calendar):
|
||||
|
|
@ -512,7 +520,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
editor = MarkdownEditor(self.themes)
|
||||
|
||||
# Apply user’s preferred font size
|
||||
# Apply user's preferred font size
|
||||
self._apply_font_size(editor)
|
||||
|
||||
# Set up the editor's event connections
|
||||
|
|
@ -814,9 +822,13 @@ class MainWindow(QMainWindow):
|
|||
Given a 'new day' (system date), return the date we should move
|
||||
unfinished todos *to*.
|
||||
|
||||
If the new day is Saturday or Sunday, we skip ahead to the next Monday.
|
||||
Otherwise we just return the same day.
|
||||
By default, if the new day is Saturday or Sunday we skip ahead to the
|
||||
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
|
||||
dow = day.dayOfWeek()
|
||||
if dow >= 6: # Saturday (6) or Sunday (7)
|
||||
|
|
@ -871,7 +883,74 @@ class MainWindow(QMainWindow):
|
|||
target_date = self._rollover_target_date(today)
|
||||
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
|
||||
|
||||
# Look back N days (yesterday = 1, up to `days_back`)
|
||||
|
|
@ -885,14 +964,24 @@ class MainWindow(QMainWindow):
|
|||
lines = text.split("\n")
|
||||
remaining_lines: list[str] = []
|
||||
moved_from_this_day = False
|
||||
current_heading: tuple[int, str] | None = None
|
||||
|
||||
for line in lines:
|
||||
# Track the last seen heading (# / ## / ###)
|
||||
m_head = heading_re.match(line)
|
||||
if m_head:
|
||||
level = len(m_head.group(1))
|
||||
heading_text = _normalize_heading(m_head.group(2))
|
||||
if level <= 3:
|
||||
current_heading = (level, heading_text)
|
||||
# Keep headings in the original day
|
||||
remaining_lines.append(line)
|
||||
continue
|
||||
|
||||
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
|
||||
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
||||
r"^\s*-\s*\[☐\]\s+", line
|
||||
):
|
||||
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
||||
all_unchecked.append(f"- [ ] {item_text}")
|
||||
if unchecked_re.match(line):
|
||||
item_text = unchecked_re.sub("", line)
|
||||
moved_items.append((current_heading, item_text))
|
||||
moved_from_this_day = True
|
||||
any_moved = True
|
||||
else:
|
||||
|
|
@ -910,9 +999,45 @@ class MainWindow(QMainWindow):
|
|||
if not any_moved:
|
||||
return False
|
||||
|
||||
# Append everything we collected to the *target* date
|
||||
unchecked_str = "\n".join(all_unchecked) + "\n"
|
||||
self._load_selected_date(target_iso, unchecked_str)
|
||||
# --- Merge all moved items into the *target* date ---
|
||||
|
||||
target_text = self.db.get_entry(target_iso) or ""
|
||||
target_lines = target_text.split("\n") if target_text else []
|
||||
|
||||
by_heading: dict[tuple[int, str], list[str]] = {}
|
||||
plain_items: list[str] = []
|
||||
|
||||
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
|
||||
|
||||
def _on_date_changed(self):
|
||||
|
|
@ -1020,22 +1145,10 @@ class MainWindow(QMainWindow):
|
|||
save_db_config(cfg)
|
||||
|
||||
def _retheme_overrides(self):
|
||||
self._apply_calendar_text_colors()
|
||||
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
||||
self.calendar.update()
|
||||
self.editor.viewport().update()
|
||||
|
||||
def _apply_calendar_text_colors(self):
|
||||
pal = QApplication.instance().palette()
|
||||
txt = pal.windowText().color()
|
||||
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setForeground(txt)
|
||||
|
||||
# Use normal text color for weekends
|
||||
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
|
||||
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
|
||||
|
||||
# --------------- Search sidebar/results helpers ---------------- #
|
||||
|
||||
def _on_search_dates_changed(self, date_strs: list[str]):
|
||||
|
|
@ -1105,6 +1218,7 @@ class MainWindow(QMainWindow):
|
|||
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
|
||||
self._tb_alarm = self._on_alarm_requested
|
||||
self._tb_timer = self._on_timer_requested
|
||||
self._tb_documents = self._on_documents_requested
|
||||
self._tb_font_larger = self._on_font_larger_requested
|
||||
self._tb_font_smaller = self._on_font_smaller_requested
|
||||
|
||||
|
|
@ -1118,6 +1232,7 @@ class MainWindow(QMainWindow):
|
|||
tb.checkboxesRequested.connect(self._tb_checkboxes)
|
||||
tb.alarmRequested.connect(self._tb_alarm)
|
||||
tb.timerRequested.connect(self._tb_timer)
|
||||
tb.documentsRequested.connect(self._tb_documents)
|
||||
tb.insertImageRequested.connect(self._on_insert_image)
|
||||
tb.historyRequested.connect(self._open_history)
|
||||
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
|
||||
|
|
@ -1200,22 +1315,35 @@ class MainWindow(QMainWindow):
|
|||
self.upcoming_reminders._add_reminder()
|
||||
|
||||
def _on_timer_requested(self):
|
||||
"""Start a Pomodoro timer for the current line."""
|
||||
editor = getattr(self, "editor", None)
|
||||
if editor is None:
|
||||
return
|
||||
"""Toggle the embedded Pomodoro timer for the current line."""
|
||||
action = self.toolBar.actTimer
|
||||
|
||||
# Get the current line text
|
||||
line_text = editor.get_current_line_task_text()
|
||||
# Turned on -> start a new timer for the current line
|
||||
if action.isChecked():
|
||||
editor = getattr(self, "editor", None)
|
||||
if editor is None:
|
||||
# No editor; immediately reset the toggle
|
||||
action.setChecked(False)
|
||||
return
|
||||
|
||||
if not line_text:
|
||||
line_text = strings._("pomodoro_time_log_default_text")
|
||||
# Get the current line text
|
||||
line_text = editor.get_current_line_task_text()
|
||||
if not line_text:
|
||||
line_text = strings._("pomodoro_time_log_default_text")
|
||||
|
||||
# Get current date
|
||||
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
# Get current date
|
||||
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
||||
|
||||
# Start the timer
|
||||
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
|
||||
# Start the timer embedded in the sidebar
|
||||
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
|
||||
else:
|
||||
# Turned off -> cancel any running timer and remove the widget
|
||||
self.pomodoro_manager.cancel_timer()
|
||||
|
||||
def _send_reminder_webhook(self, text: str):
|
||||
if self.cfg.reminders and self.cfg.reminders_webhook_url:
|
||||
reminder_webhook = ReminderWebHook(text)
|
||||
reminder_webhook._send()
|
||||
|
||||
def _show_flashing_reminder(self, text: str):
|
||||
"""
|
||||
|
|
@ -1334,6 +1462,14 @@ class MainWindow(QMainWindow):
|
|||
timer.start(msecs)
|
||||
self._reminder_timers.append(timer)
|
||||
|
||||
# ----------- Documents handler ------------#
|
||||
def _on_documents_requested(self):
|
||||
documents_dlg = DocumentsDialog(self.db, self)
|
||||
documents_dlg.exec()
|
||||
# Refresh recent documents after any changes
|
||||
if hasattr(self, "todays_documents"):
|
||||
self.todays_documents.reload()
|
||||
|
||||
# ----------- History handler ------------#
|
||||
def _open_history(self):
|
||||
if hasattr(self.editor, "current_date"):
|
||||
|
|
@ -1341,7 +1477,7 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
date_iso = self._current_date_iso()
|
||||
|
||||
dlg = HistoryDialog(self.db, date_iso, self)
|
||||
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
# refresh editor + calendar (head pointer may have changed)
|
||||
self._load_selected_date(date_iso)
|
||||
|
|
@ -1368,6 +1504,8 @@ class MainWindow(QMainWindow):
|
|||
self.tags.set_current_date(date_iso)
|
||||
if hasattr(self, "time_log"):
|
||||
self.time_log.set_current_date(date_iso)
|
||||
if hasattr(self, "todays_documents"):
|
||||
self.todays_documents.set_current_date(date_iso)
|
||||
|
||||
def _on_tag_added(self):
|
||||
"""Called when a tag is added - trigger autosave for current page"""
|
||||
|
|
@ -1432,9 +1570,22 @@ class MainWindow(QMainWindow):
|
|||
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.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.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
||||
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.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
|
||||
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
||||
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
||||
|
||||
|
|
@ -1472,6 +1623,12 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
self.upcoming_reminders.show()
|
||||
self.toolBar.actAlarm.setVisible(True)
|
||||
if not self.cfg.documents:
|
||||
self.todays_documents.hide()
|
||||
self.toolBar.actDocuments.setVisible(False)
|
||||
else:
|
||||
self.todays_documents.show()
|
||||
self.toolBar.actDocuments.setVisible(True)
|
||||
|
||||
# ------------ Statistics handler --------------- #
|
||||
|
||||
|
|
|
|||
|
|
@ -5,28 +5,28 @@ import re
|
|||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QRect, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import (
|
||||
QDesktopServices,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QFontMetrics,
|
||||
QImage,
|
||||
QMouseEvent,
|
||||
QTextBlock,
|
||||
QTextBlockFormat,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
QTextFormat,
|
||||
QTextBlockFormat,
|
||||
QTextImageFormat,
|
||||
QDesktopServices,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
|
||||
from PySide6.QtWidgets import QDialog, QTextEdit
|
||||
|
||||
from .theme import ThemeManager
|
||||
from .markdown_highlighter import MarkdownHighlighter
|
||||
from .code_block_editor_dialog import CodeBlockEditorDialog
|
||||
from . import strings
|
||||
from .code_block_editor_dialog import CodeBlockEditorDialog
|
||||
from .markdown_highlighter import MarkdownHighlighter
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
||||
class MarkdownEditor(QTextEdit):
|
||||
|
|
@ -98,6 +98,10 @@ class MarkdownEditor(QTextEdit):
|
|||
# Guard to avoid recursive selection tweaks
|
||||
self._adjusting_selection = False
|
||||
|
||||
# Track when the current selection is being created via mouse drag,
|
||||
# so we can treat it differently from triple-click / keyboard selections.
|
||||
self._mouse_drag_selecting = False
|
||||
|
||||
# After selections change, trim list prefixes from full-line selections
|
||||
# (e.g. after triple-clicking a list item to select the line).
|
||||
self.selectionChanged.connect(self._maybe_trim_list_prefix_from_line_selection)
|
||||
|
|
@ -118,9 +122,12 @@ class MarkdownEditor(QTextEdit):
|
|||
)
|
||||
|
||||
def setDocument(self, doc):
|
||||
super().setDocument(doc)
|
||||
# Recreate the highlighter for the new document
|
||||
# (the old one gets deleted with the old document)
|
||||
if doc is None:
|
||||
return
|
||||
|
||||
super().setDocument(doc)
|
||||
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
|
||||
self.highlighter = MarkdownHighlighter(
|
||||
self.document(), self.theme_manager, self
|
||||
|
|
@ -214,6 +221,9 @@ class MarkdownEditor(QTextEdit):
|
|||
if doc is None:
|
||||
return
|
||||
|
||||
if not hasattr(self, "highlighter") or self.highlighter is None:
|
||||
return
|
||||
|
||||
bg_brush = self.highlighter.code_block_format.background()
|
||||
|
||||
selections: list[QTextEdit.ExtraSelection] = []
|
||||
|
|
@ -372,7 +382,7 @@ class MarkdownEditor(QTextEdit):
|
|||
cursor.removeSelectedText()
|
||||
cursor.insertText("\n" + new_text + "\n")
|
||||
else:
|
||||
# Empty block – keep one blank line inside the fences
|
||||
# Empty block - keep one blank line inside the fences
|
||||
cursor.removeSelectedText()
|
||||
cursor.insertText("\n\n")
|
||||
cursor.endEditBlock()
|
||||
|
|
@ -777,6 +787,12 @@ class MarkdownEditor(QTextEdit):
|
|||
just *after* the visual list prefix (checkbox / bullet / number), and
|
||||
ends at the end of the text on that line (not on the next line's newline).
|
||||
"""
|
||||
# When the user is actively dragging with the mouse, we *do* want the
|
||||
# checkbox/bullet to be part of the selection (for deleting whole rows).
|
||||
# So don't rewrite the selection in that case.
|
||||
if getattr(self, "_mouse_drag_selecting", False):
|
||||
return
|
||||
|
||||
# Avoid re-entry when we move the cursor ourselves.
|
||||
if getattr(self, "_adjusting_selection", False):
|
||||
return
|
||||
|
|
@ -847,7 +863,7 @@ class MarkdownEditor(QTextEdit):
|
|||
):
|
||||
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
||||
|
||||
# Bullet list – Unicode bullet
|
||||
# Bullet list - Unicode bullet
|
||||
if line.startswith(f"{self._BULLET_DISPLAY} "):
|
||||
return ("bullet", f"{self._BULLET_DISPLAY} ")
|
||||
|
||||
|
|
@ -1039,7 +1055,7 @@ class MarkdownEditor(QTextEdit):
|
|||
# of list prefixes (checkboxes / bullets / numbers).
|
||||
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
|
||||
# Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
|
||||
# document / word-left) – we don't interfere with those.
|
||||
# document / word-left) - we don't interfere with those.
|
||||
if event.modifiers() & Qt.ControlModifier:
|
||||
pass
|
||||
else:
|
||||
|
|
@ -1194,7 +1210,7 @@ class MarkdownEditor(QTextEdit):
|
|||
return
|
||||
else:
|
||||
# Not empty - continue the list
|
||||
self._last_enter_was_empty = False
|
||||
self._last_enter_was_empty = True
|
||||
|
||||
# Insert newline and continue the list
|
||||
super().keyPressEvent(event)
|
||||
|
|
@ -1211,6 +1227,13 @@ class MarkdownEditor(QTextEdit):
|
|||
super().keyPressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
# If the left button is down while the mouse moves, we consider this
|
||||
# a drag selection (as opposed to a simple click).
|
||||
if event.buttons() & Qt.LeftButton:
|
||||
self._mouse_drag_selecting = True
|
||||
else:
|
||||
self._mouse_drag_selecting = False
|
||||
|
||||
# Change cursor when hovering a link
|
||||
url = self._url_at_pos(event.pos())
|
||||
if url:
|
||||
|
|
@ -1224,6 +1247,12 @@ class MarkdownEditor(QTextEdit):
|
|||
# Let QTextEdit handle caret/selection first
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
if event.button() == Qt.LeftButton:
|
||||
# At this point the drag (if any) has finished and the final
|
||||
# selection is already in place (and selectionChanged has fired).
|
||||
# Clear the drag flag for future interactions.
|
||||
self._mouse_drag_selecting = False
|
||||
|
||||
if event.button() != Qt.LeftButton:
|
||||
return
|
||||
|
||||
|
|
@ -1246,7 +1275,10 @@ class MarkdownEditor(QTextEdit):
|
|||
# default: don't suppress any upcoming double-click
|
||||
self._suppress_next_checkbox_double_click = False
|
||||
|
||||
# Fresh left-button press starts with "no drag" yet.
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._mouse_drag_selecting = False
|
||||
|
||||
pt = event.pos()
|
||||
|
||||
# Cursor and block under the mouse
|
||||
|
|
@ -1285,15 +1317,43 @@ class MarkdownEditor(QTextEdit):
|
|||
if icon:
|
||||
# absolute document position of the icon
|
||||
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 ----------
|
||||
# Expand the clickable area horizontally so you don't have to
|
||||
# land exactly on the glyph. This makes the "checkbox zone"
|
||||
# roughly 3× the glyph width, centered on it.
|
||||
pad = r.width() # one glyph width on each side
|
||||
hit_rect = r.adjusted(-pad, 0, pad, 0)
|
||||
# ---------------------------------------------
|
||||
# --- Find where the first non-space "real text" starts ---
|
||||
first_idx = i + len(icon) + 1 # skip icon + trailing space
|
||||
while first_idx < len(text) and text[first_idx].isspace():
|
||||
first_idx += 1
|
||||
|
||||
# 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):
|
||||
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
|
||||
|
|
@ -1307,7 +1367,9 @@ class MarkdownEditor(QTextEdit):
|
|||
edit.setPosition(doc_pos)
|
||||
# icon + space
|
||||
edit.movePosition(
|
||||
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
|
||||
QTextCursor.Right,
|
||||
QTextCursor.KeepAnchor,
|
||||
len(icon) + 1,
|
||||
)
|
||||
edit.insertText(f"{new_icon} ")
|
||||
edit.endEditBlock()
|
||||
|
|
@ -1335,7 +1397,7 @@ class MarkdownEditor(QTextEdit):
|
|||
cursor = self.cursorForPosition(event.pos())
|
||||
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("```"):
|
||||
# Only swallow the double-click if we actually opened a dialog.
|
||||
if not self._edit_code_block(block):
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from PySide6.QtGui import (
|
|||
QTextDocument,
|
||||
)
|
||||
|
||||
from .theme import ThemeManager, Theme
|
||||
from .theme import Theme, ThemeManager
|
||||
|
||||
|
||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||
|
|
@ -356,7 +356,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
for m in re.finditer(r"[☐☑]", text):
|
||||
self._overlay_range(m.start(), 1, self.checkbox_format)
|
||||
|
||||
# (If you add Unicode bullets later…)
|
||||
for m in re.finditer(r"•", text):
|
||||
self._overlay_range(m.start(), 1, self.bullet_format)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ from typing import Optional
|
|||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
|
@ -18,16 +18,13 @@ from .db import DBManager
|
|||
from .time_log import TimeLogDialog
|
||||
|
||||
|
||||
class PomodoroTimer(QDialog):
|
||||
"""A simple timer dialog for tracking work time on a specific task."""
|
||||
class PomodoroTimer(QFrame):
|
||||
"""A simple timer for tracking work time on a specific task."""
|
||||
|
||||
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
|
||||
|
||||
def __init__(self, task_text: str, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
|
||||
self.setModal(False)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
self._task_text = task_text
|
||||
self._elapsed_seconds = 0
|
||||
|
|
@ -43,7 +40,7 @@ class PomodoroTimer(QDialog):
|
|||
# Timer display
|
||||
self.time_label = QLabel("00:00:00")
|
||||
font = self.time_label.font()
|
||||
font.setPointSize(24)
|
||||
font.setPointSize(20)
|
||||
font.setBold(True)
|
||||
self.time_label.setFont(font)
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
|
|
@ -103,7 +100,7 @@ class PomodoroTimer(QDialog):
|
|||
self._timer.stop()
|
||||
|
||||
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
|
||||
self.accept()
|
||||
self.close()
|
||||
|
||||
|
||||
class PomodoroManager:
|
||||
|
|
@ -115,29 +112,80 @@ class PomodoroManager:
|
|||
self._active_timer: Optional[PomodoroTimer] = None
|
||||
|
||||
def start_timer_for_line(self, line_text: str, date_iso: str):
|
||||
"""Start a new timer for the given line of text."""
|
||||
# Stop any existing timer
|
||||
if self._active_timer and self._active_timer.isVisible():
|
||||
self._active_timer.close()
|
||||
"""
|
||||
Start a new timer for the given line of text and embed it into the
|
||||
TimeLogWidget in the main window sidebar.
|
||||
"""
|
||||
# Cancel any existing timer first
|
||||
self.cancel_timer()
|
||||
|
||||
# Create new timer
|
||||
self._active_timer = PomodoroTimer(line_text, self._parent)
|
||||
# The timer lives inside the TimeLogWidget in the sidebar
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is None:
|
||||
return
|
||||
|
||||
self._active_timer = PomodoroTimer(line_text, time_log_widget)
|
||||
self._active_timer.timerStopped.connect(
|
||||
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
|
||||
)
|
||||
self._active_timer.show()
|
||||
|
||||
# Ask the TimeLogWidget to own and display the widget
|
||||
if hasattr(time_log_widget, "show_pomodoro_widget"):
|
||||
time_log_widget.show_pomodoro_widget(self._active_timer)
|
||||
else:
|
||||
# Fallback - just attach it as a child widget
|
||||
self._active_timer.setParent(time_log_widget)
|
||||
self._active_timer.show()
|
||||
|
||||
def cancel_timer(self):
|
||||
"""Cancel any running timer without logging and remove it from the sidebar."""
|
||||
if not self._active_timer:
|
||||
return
|
||||
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is not None and hasattr(
|
||||
time_log_widget, "clear_pomodoro_widget"
|
||||
):
|
||||
time_log_widget.clear_pomodoro_widget()
|
||||
else:
|
||||
# Fallback if the widget API doesn't exist
|
||||
self._active_timer.setParent(None)
|
||||
|
||||
self._active_timer.deleteLater()
|
||||
self._active_timer = None
|
||||
|
||||
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
|
||||
"""Handle timer stop - open time log dialog with pre-filled data."""
|
||||
# Convert seconds to decimal hours, rounded up
|
||||
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
|
||||
# Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes)
|
||||
quarter_hours = math.ceil(elapsed_seconds / 900)
|
||||
hours = quarter_hours * 0.25
|
||||
|
||||
# Ensure minimum of 0.25 hours
|
||||
if hours < 0.25:
|
||||
hours = 0.25
|
||||
|
||||
# Untoggle the toolbar button without retriggering the slot
|
||||
tool_bar = getattr(self._parent, "toolBar", None)
|
||||
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
|
||||
action = tool_bar.actTimer
|
||||
was_blocked = action.blockSignals(True)
|
||||
try:
|
||||
action.setChecked(False)
|
||||
finally:
|
||||
action.blockSignals(was_blocked)
|
||||
|
||||
# Remove the embedded widget
|
||||
self.cancel_timer()
|
||||
|
||||
# Open time log dialog
|
||||
dlg = TimeLogDialog(self._db, date_iso, self._parent)
|
||||
dlg = TimeLogDialog(
|
||||
self._db,
|
||||
date_iso,
|
||||
self._parent,
|
||||
True,
|
||||
themes=self._parent.themes,
|
||||
close_after_add=True,
|
||||
)
|
||||
|
||||
# Pre-fill the hours
|
||||
dlg.hours_spin.setValue(hours)
|
||||
|
|
@ -147,3 +195,13 @@ class PomodoroManager:
|
|||
|
||||
# Show the dialog
|
||||
dlg.exec()
|
||||
|
||||
time_log_widget = getattr(self._parent, "time_log", None)
|
||||
if time_log_widget is not None:
|
||||
# Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
|
||||
# reload the summary so the TimeLogWidget in sidebar updates its totals
|
||||
time_log_widget._reload_summary()
|
||||
if not time_log_widget.toggle_btn.isChecked():
|
||||
time_log_widget.summary_label.setText(
|
||||
strings._("time_log_collapsed_hint")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,32 +4,37 @@ from dataclasses import dataclass
|
|||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
|
||||
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QTimeEdit,
|
||||
QPushButton,
|
||||
QDateEdit,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QWidget,
|
||||
QToolButton,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QStyle,
|
||||
QSizePolicy,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
QTimeEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .settings import load_db_config
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class ReminderType(Enum):
|
||||
|
|
@ -37,6 +42,9 @@ class ReminderType(Enum):
|
|||
DAILY = strings._("daily")
|
||||
WEEKDAYS = strings._("weekdays") # Mon-Fri
|
||||
WEEKLY = strings._("weekly") # specific day of week
|
||||
FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
|
||||
MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
|
||||
MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -72,6 +80,22 @@ class ReminderDialog(QDialog):
|
|||
self.text_edit.setText(reminder.text)
|
||||
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
|
||||
self.time_edit = QTimeEdit()
|
||||
self.time_edit.setDisplayFormat("HH:mm")
|
||||
|
|
@ -79,15 +103,22 @@ class ReminderDialog(QDialog):
|
|||
parts = reminder.time_str.split(":")
|
||||
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
|
||||
else:
|
||||
self.time_edit.setTime(QTime.currentTime())
|
||||
# Default to 5 minutes in the future
|
||||
future = QTime.currentTime().addSecs(5 * 60)
|
||||
self.time_edit.setTime(future)
|
||||
self.form.addRow("&" + strings._("time") + ":", self.time_edit)
|
||||
|
||||
# Recurrence type
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
|
||||
self.type_combo.addItem(strings._("once"), ReminderType.ONCE)
|
||||
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
|
||||
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
||||
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
||||
self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
|
||||
self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
|
||||
self.type_combo.addItem(
|
||||
strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
)
|
||||
|
||||
if reminder:
|
||||
for i in range(self.type_combo.count()):
|
||||
|
|
@ -115,12 +146,31 @@ class ReminderDialog(QDialog):
|
|||
if reminder and reminder.weekday is not None:
|
||||
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
||||
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)
|
||||
day_label = self.form.labelForField(self.weekday_combo)
|
||||
day_label.setVisible(False)
|
||||
|
||||
self.nth_spin = QSpinBox()
|
||||
self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
|
||||
self.nth_spin.setValue(1)
|
||||
# If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
|
||||
if (
|
||||
reminder
|
||||
and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
and reminder.date_iso
|
||||
):
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if anchor.isValid():
|
||||
nth_index = (anchor.day() - 1) // 7 # 0-based
|
||||
self.nth_spin.setValue(nth_index + 1)
|
||||
|
||||
self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
|
||||
nth_label = self.form.labelForField(self.nth_spin)
|
||||
nth_label.setVisible(False)
|
||||
self.nth_spin.setVisible(False)
|
||||
|
||||
layout.addLayout(self.form)
|
||||
|
||||
# Buttons
|
||||
|
|
@ -141,11 +191,31 @@ class ReminderDialog(QDialog):
|
|||
self._on_type_changed()
|
||||
|
||||
def _on_type_changed(self):
|
||||
"""Show/hide weekday selector based on reminder type."""
|
||||
"""Show/hide weekday / nth selectors based on reminder type."""
|
||||
reminder_type = self.type_combo.currentData()
|
||||
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
|
||||
|
||||
show_weekday = reminder_type in (
|
||||
ReminderType.WEEKLY,
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY,
|
||||
)
|
||||
self.weekday_combo.setVisible(show_weekday)
|
||||
day_label = self.form.labelForField(self.weekday_combo)
|
||||
day_label.setVisible(reminder_type == ReminderType.WEEKLY)
|
||||
day_label.setVisible(show_weekday)
|
||||
|
||||
show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
||||
nth_label = self.form.labelForField(self.nth_spin)
|
||||
self.nth_spin.setVisible(show_nth)
|
||||
nth_label.setVisible(show_nth)
|
||||
|
||||
# For new reminders, when switching to a type that uses a weekday,
|
||||
# snap the weekday to match the currently selected date.
|
||||
if reminder_type in (
|
||||
ReminderType.WEEKLY,
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY,
|
||||
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
|
||||
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
|
||||
if 0 <= dow < self.weekday_combo.count():
|
||||
self.weekday_combo.setCurrentIndex(dow)
|
||||
|
||||
def get_reminder(self) -> Reminder:
|
||||
"""Get the configured reminder."""
|
||||
|
|
@ -154,13 +224,39 @@ class ReminderDialog(QDialog):
|
|||
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
|
||||
|
||||
weekday = None
|
||||
if reminder_type == ReminderType.WEEKLY:
|
||||
if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY):
|
||||
weekday = self.weekday_combo.currentData()
|
||||
|
||||
date_iso = None
|
||||
anchor_date = self.date_edit.date()
|
||||
|
||||
if reminder_type == ReminderType.ONCE:
|
||||
# Right now this just means "today at the chosen time".
|
||||
date_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
# Fire once, on the chosen calendar date at the chosen time
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.FORTNIGHTLY:
|
||||
# Anchor: the chosen calendar date. Every 14 days from this date.
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.MONTHLY_DATE:
|
||||
# Anchor: the chosen calendar date. "Same date each month"
|
||||
date_iso = anchor_date.toString("yyyy-MM-dd")
|
||||
|
||||
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
|
||||
weekday = self.weekday_combo.currentData()
|
||||
nth_index = self.nth_spin.value() - 1 # 0-based
|
||||
|
||||
first = QDate(anchor_date.year(), anchor_date.month(), 1)
|
||||
target_dow = weekday + 1 # Qt: Monday=1
|
||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||
anchor = first.addDays(offset + nth_index * 7)
|
||||
|
||||
# If nth weekday doesn't exist in this month, fall back to the last such weekday
|
||||
if anchor.month() != anchor_date.month():
|
||||
anchor = anchor.addDays(-7)
|
||||
|
||||
date_iso = anchor.toString("yyyy-MM-dd")
|
||||
|
||||
return Reminder(
|
||||
id=self._reminder.id if self._reminder else None,
|
||||
|
|
@ -187,7 +283,7 @@ class UpcomingRemindersWidget(QFrame):
|
|||
|
||||
# Header with toggle button
|
||||
self.toggle_btn = QToolButton()
|
||||
self.toggle_btn.setText("Upcoming Reminders")
|
||||
self.toggle_btn.setText(strings._("upcoming_reminders"))
|
||||
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle_btn.setCheckable(True)
|
||||
self.toggle_btn.setChecked(False)
|
||||
|
|
@ -196,7 +292,7 @@ class UpcomingRemindersWidget(QFrame):
|
|||
|
||||
self.add_btn = QToolButton()
|
||||
self.add_btn.setText("⏰")
|
||||
self.add_btn.setToolTip("Add Reminder")
|
||||
self.add_btn.setToolTip(strings._("add_reminder"))
|
||||
self.add_btn.setAutoRaise(True)
|
||||
self.add_btn.clicked.connect(self._add_reminder)
|
||||
|
||||
|
|
@ -204,7 +300,7 @@ class UpcomingRemindersWidget(QFrame):
|
|||
self.manage_btn.setIcon(
|
||||
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
||||
)
|
||||
self.manage_btn.setToolTip("Manage All Reminders")
|
||||
self.manage_btn.setToolTip(strings._("manage_reminders"))
|
||||
self.manage_btn.setAutoRaise(True)
|
||||
self.manage_btn.clicked.connect(self._manage_reminders)
|
||||
|
||||
|
|
@ -239,43 +335,36 @@ class UpcomingRemindersWidget(QFrame):
|
|||
main.addWidget(self.body)
|
||||
|
||||
# Timer to check and fire reminders
|
||||
# Start by syncing to the next minute boundary
|
||||
self._check_timer = QTimer(self)
|
||||
self._check_timer.timeout.connect(self._check_reminders)
|
||||
#
|
||||
# We tick once per second, but only hit the DB when the clock is
|
||||
# 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()
|
||||
current_second = now.time().second()
|
||||
current_msec = now.time().msec()
|
||||
|
||||
# Milliseconds until next minute
|
||||
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
|
||||
|
||||
# Start with a single-shot to sync to the minute
|
||||
self._sync_timer = QTimer(self)
|
||||
self._sync_timer.setSingleShot(True)
|
||||
self._sync_timer.timeout.connect(self._start_regular_timer)
|
||||
self._sync_timer.start(ms_until_next_minute)
|
||||
|
||||
# Also check immediately in case there are pending reminders
|
||||
QTimer.singleShot(1000, self._check_reminders)
|
||||
if now.time().second() == 0:
|
||||
# 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
|
||||
# were created.
|
||||
self._check_reminders(now)
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup timers when widget is destroyed."""
|
||||
try:
|
||||
if hasattr(self, "_check_timer") and self._check_timer:
|
||||
self._check_timer.stop()
|
||||
if hasattr(self, "_sync_timer") and self._sync_timer:
|
||||
self._sync_timer.stop()
|
||||
except:
|
||||
if hasattr(self, "_tick_timer") and self._tick_timer:
|
||||
self._tick_timer.stop()
|
||||
except Exception:
|
||||
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):
|
||||
"""Toggle visibility of reminder list."""
|
||||
self.body.setVisible(checked)
|
||||
|
|
@ -328,41 +417,99 @@ class UpcomingRemindersWidget(QFrame):
|
|||
self.reminder_list.addItem(item)
|
||||
|
||||
if not upcoming:
|
||||
item = QListWidgetItem("No upcoming reminders")
|
||||
item = QListWidgetItem(strings._("no_upcoming_reminders"))
|
||||
item.setFlags(Qt.NoItemFlags)
|
||||
self.reminder_list.addItem(item)
|
||||
|
||||
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
|
||||
"""Check if a reminder should fire on a given date."""
|
||||
if reminder.reminder_type == ReminderType.ONCE:
|
||||
rtype = reminder.reminder_type
|
||||
|
||||
if rtype == ReminderType.ONCE:
|
||||
if reminder.date_iso:
|
||||
return date.toString("yyyy-MM-dd") == reminder.date_iso
|
||||
return False
|
||||
elif reminder.reminder_type == ReminderType.DAILY:
|
||||
|
||||
if rtype == ReminderType.DAILY:
|
||||
return True
|
||||
elif reminder.reminder_type == ReminderType.WEEKDAYS:
|
||||
|
||||
if rtype == ReminderType.WEEKDAYS:
|
||||
# Monday=1, Sunday=7
|
||||
return 1 <= date.dayOfWeek() <= 5
|
||||
elif reminder.reminder_type == ReminderType.WEEKLY:
|
||||
|
||||
if rtype == ReminderType.WEEKLY:
|
||||
# Qt: Monday=1, reminder: Monday=0
|
||||
return date.dayOfWeek() - 1 == reminder.weekday
|
||||
|
||||
if rtype == ReminderType.FORTNIGHTLY:
|
||||
if not reminder.date_iso:
|
||||
return False
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid() or date < anchor:
|
||||
return False
|
||||
days = anchor.daysTo(date)
|
||||
return days % 14 == 0
|
||||
|
||||
if rtype == ReminderType.MONTHLY_DATE:
|
||||
if not reminder.date_iso:
|
||||
return False
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid():
|
||||
return False
|
||||
anchor_day = anchor.day()
|
||||
# Clamp to the last day of this month (for 29/30/31)
|
||||
first_of_month = QDate(date.year(), date.month(), 1)
|
||||
last_of_month = first_of_month.addMonths(1).addDays(-1)
|
||||
target_day = min(anchor_day, last_of_month.day())
|
||||
return date.day() == target_day
|
||||
|
||||
if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
if not reminder.date_iso or reminder.weekday is None:
|
||||
return False
|
||||
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if not anchor.isValid():
|
||||
return False
|
||||
|
||||
# Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
|
||||
anchor_n = (anchor.day() - 1) // 7
|
||||
target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
|
||||
|
||||
# Compute the anchor_n-th target weekday in this month
|
||||
first = QDate(date.year(), date.month(), 1)
|
||||
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
||||
candidate = first.addDays(offset + anchor_n * 7)
|
||||
|
||||
# If that nth weekday doesn't exist this month (e.g. 5th Monday), skip
|
||||
if candidate.month() != date.month():
|
||||
return False
|
||||
|
||||
return date == candidate
|
||||
|
||||
return False
|
||||
|
||||
def _check_reminders(self):
|
||||
"""Check if any reminders should fire now."""
|
||||
def _check_reminders(self, now: QDateTime | None = None):
|
||||
"""
|
||||
Check and trigger due reminders.
|
||||
|
||||
This uses absolute clock time, so a reminder for HH:MM will fire
|
||||
when the system clock reaches HH:MM:00, independent of when the
|
||||
reminder was created.
|
||||
"""
|
||||
# Guard: Check if database connection is valid
|
||||
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
|
||||
return
|
||||
|
||||
now = QDateTime.currentDateTime()
|
||||
today = QDate.currentDate()
|
||||
|
||||
# Round current time to the minute (set seconds to 0)
|
||||
current_minute = QDateTime(
|
||||
today, QTime(now.time().hour(), now.time().minute(), 0)
|
||||
)
|
||||
if now is None:
|
||||
now = QDateTime.currentDateTime()
|
||||
|
||||
today = now.date()
|
||||
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:
|
||||
if not reminder.active:
|
||||
continue
|
||||
|
|
@ -370,28 +517,35 @@ class UpcomingRemindersWidget(QFrame):
|
|||
if not self._should_fire_on_date(reminder, today):
|
||||
continue
|
||||
|
||||
# Parse time
|
||||
# Parse time: stored as "HH:MM", we treat that as HH:MM:00
|
||||
hour, minute = map(int, reminder.time_str.split(":"))
|
||||
target = QDateTime(today, QTime(hour, minute, 0))
|
||||
|
||||
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
|
||||
seconds_diff = current_minute.secsTo(target)
|
||||
if -120 <= seconds_diff <= 0:
|
||||
# Check if we haven't already fired this one
|
||||
# Skip if this reminder is still in the future
|
||||
if now < target:
|
||||
continue
|
||||
|
||||
# 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"):
|
||||
self._fired_reminders = {}
|
||||
|
||||
reminder_key = (reminder.id, target.toString())
|
||||
|
||||
# Only fire once per reminder per target time
|
||||
if reminder_key not in self._fired_reminders:
|
||||
self._fired_reminders[reminder_key] = current_minute
|
||||
self.reminderTriggered.emit(reminder.text)
|
||||
if reminder_key in self._fired_reminders:
|
||||
continue
|
||||
|
||||
# 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
|
||||
# Mark as fired and emit
|
||||
self._fired_reminders[reminder_key] = now
|
||||
self.reminderTriggered.emit(reminder.text)
|
||||
|
||||
# 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()
|
||||
def _add_reminder(self):
|
||||
|
|
@ -422,8 +576,8 @@ class UpcomingRemindersWidget(QFrame):
|
|||
if not selected_items:
|
||||
return
|
||||
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
|
|
@ -431,7 +585,7 @@ class UpcomingRemindersWidget(QFrame):
|
|||
if len(selected_items) == 1:
|
||||
reminder = selected_items[0].data(Qt.UserRole)
|
||||
if reminder:
|
||||
edit_action = QAction("Edit", self)
|
||||
edit_action = QAction(strings._("edit"), self)
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_reminder(selected_items[0])
|
||||
)
|
||||
|
|
@ -439,9 +593,13 @@ class UpcomingRemindersWidget(QFrame):
|
|||
|
||||
# Delete option for any selection
|
||||
if len(selected_items) == 1:
|
||||
delete_text = "Delete"
|
||||
delete_text = strings._("delete")
|
||||
else:
|
||||
delete_text = f"Delete {len(selected_items)} Reminders"
|
||||
delete_text = (
|
||||
strings._("delete")
|
||||
+ f" {len(selected_items)} "
|
||||
+ strings._("reminders")
|
||||
)
|
||||
|
||||
delete_action = QAction(delete_text, self)
|
||||
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
|
||||
|
|
@ -468,15 +626,31 @@ class UpcomingRemindersWidget(QFrame):
|
|||
# Confirmation message
|
||||
if len(unique_reminders) == 1:
|
||||
reminder = list(unique_reminders.values())[0]
|
||||
msg = f"Delete reminder '{reminder.text}'?"
|
||||
msg = (
|
||||
strings._("delete")
|
||||
+ " "
|
||||
+ strings._("reminder")
|
||||
+ f" '{reminder.text}'?"
|
||||
)
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
|
||||
msg += (
|
||||
"\n\n"
|
||||
+ strings._("this_is_a_reminder_of_type")
|
||||
+ f" '{reminder.reminder_type.value}'. "
|
||||
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
||||
)
|
||||
else:
|
||||
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
|
||||
msg = (
|
||||
strings._("delete")
|
||||
+ f"{len(unique_reminders)} "
|
||||
+ strings._("reminders")
|
||||
+ " ?\n\n"
|
||||
+ strings._("this_will_delete_the_actual_reminders")
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminders",
|
||||
strings._("delete_reminders"),
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
|
|
@ -489,13 +663,18 @@ class UpcomingRemindersWidget(QFrame):
|
|||
|
||||
def _delete_reminder(self, reminder):
|
||||
"""Delete a single reminder after confirmation."""
|
||||
msg = f"Delete reminder '{reminder.text}'?"
|
||||
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
|
||||
if reminder.reminder_type != ReminderType.ONCE:
|
||||
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
|
||||
msg += (
|
||||
"\n\n"
|
||||
+ strings._("this_is_a_reminder_of_type")
|
||||
+ f" '{reminder.reminder_type.value}'. "
|
||||
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminder",
|
||||
strings._("delete_reminder"),
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
|
|
@ -520,32 +699,40 @@ class ManageRemindersDialog(QDialog):
|
|||
super().__init__(parent)
|
||||
self._db = db
|
||||
|
||||
self.setWindowTitle("Manage Reminders")
|
||||
self.setWindowTitle(strings._("manage_reminders"))
|
||||
self.setMinimumSize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Reminder list table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setColumnCount(6)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
["Text", "Time", "Type", "Active", "Actions"]
|
||||
[
|
||||
strings._("text"),
|
||||
strings._("date"),
|
||||
strings._("time"),
|
||||
strings._("type"),
|
||||
strings._("active"),
|
||||
strings._("actions"),
|
||||
]
|
||||
)
|
||||
self.table.horizontalHeader().setStretchLastSection(False)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
add_btn = QPushButton("Add Reminder")
|
||||
add_btn = QPushButton(strings._("add_reminder"))
|
||||
add_btn.clicked.connect(self._add_reminder)
|
||||
btn_layout.addWidget(add_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn = QPushButton(strings._("close"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_layout.addWidget(close_btn)
|
||||
|
||||
|
|
@ -569,48 +756,105 @@ class ManageRemindersDialog(QDialog):
|
|||
text_item.setData(Qt.UserRole, reminder)
|
||||
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_item = QTableWidgetItem(reminder.time_str)
|
||||
self.table.setItem(row, 1, time_item)
|
||||
self.table.setItem(row, 2, time_item)
|
||||
|
||||
# Type
|
||||
type_str = {
|
||||
base_type_strs = {
|
||||
ReminderType.ONCE: "Once",
|
||||
ReminderType.DAILY: "Daily",
|
||||
ReminderType.WEEKDAYS: "Weekdays",
|
||||
ReminderType.WEEKLY: "Weekly",
|
||||
}.get(reminder.reminder_type, "Unknown")
|
||||
ReminderType.FORTNIGHTLY: "Fortnightly",
|
||||
ReminderType.MONTHLY_DATE: "Monthly (date)",
|
||||
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
|
||||
}
|
||||
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
|
||||
|
||||
if (
|
||||
reminder.reminder_type == ReminderType.WEEKLY
|
||||
and reminder.weekday is not None
|
||||
):
|
||||
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
type_str += f" ({days[reminder.weekday]})"
|
||||
# Short day names we can reuse
|
||||
days_short = [
|
||||
strings._("monday_short"),
|
||||
strings._("tuesday_short"),
|
||||
strings._("wednesday_short"),
|
||||
strings._("thursday_short"),
|
||||
strings._("friday_short"),
|
||||
strings._("saturday_short"),
|
||||
strings._("sunday_short"),
|
||||
]
|
||||
|
||||
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
||||
# Show something like: Monthly (3rd Mon)
|
||||
day_name = ""
|
||||
if reminder.weekday is not None and 0 <= reminder.weekday < len(
|
||||
days_short
|
||||
):
|
||||
day_name = days_short[reminder.weekday]
|
||||
|
||||
nth_label = ""
|
||||
if reminder.date_iso:
|
||||
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
||||
if anchor.isValid():
|
||||
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
|
||||
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
|
||||
if 0 <= nth_index < len(ordinals):
|
||||
nth_label = ordinals[nth_index]
|
||||
|
||||
parts = []
|
||||
if nth_label:
|
||||
parts.append(nth_label)
|
||||
if day_name:
|
||||
parts.append(day_name)
|
||||
|
||||
if parts:
|
||||
type_str = f"Monthly ({' '.join(parts)})"
|
||||
# else: fall back to the generic "Monthly (nth weekday)"
|
||||
|
||||
else:
|
||||
# For weekly / fortnightly types, still append the day name
|
||||
if (
|
||||
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)
|
||||
self.table.setItem(row, 2, type_item)
|
||||
self.table.setItem(row, 3, type_item)
|
||||
|
||||
# Active
|
||||
active_item = QTableWidgetItem("✓" if reminder.active else "✗")
|
||||
self.table.setItem(row, 3, active_item)
|
||||
self.table.setItem(row, 4, active_item)
|
||||
|
||||
# Actions
|
||||
actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(actions_widget)
|
||||
actions_layout.setContentsMargins(2, 2, 2, 2)
|
||||
|
||||
edit_btn = QPushButton("Edit")
|
||||
edit_btn = QPushButton(strings._("edit"))
|
||||
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
|
||||
actions_layout.addWidget(edit_btn)
|
||||
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn = QPushButton(strings._("delete"))
|
||||
delete_btn.clicked.connect(
|
||||
lambda checked, r=reminder: self._delete_reminder(r)
|
||||
)
|
||||
actions_layout.addWidget(delete_btn)
|
||||
|
||||
self.table.setCellWidget(row, 4, actions_widget)
|
||||
self.table.setCellWidget(row, 5, actions_widget)
|
||||
|
||||
def _add_reminder(self):
|
||||
"""Add a new reminder."""
|
||||
|
|
@ -632,8 +876,8 @@ class ManageRemindersDialog(QDialog):
|
|||
"""Delete a reminder."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Reminder",
|
||||
f"Delete reminder '{reminder.text}'?",
|
||||
strings._("delete_reminder"),
|
||||
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
|
@ -641,3 +885,33 @@ class ManageRemindersDialog(QDialog):
|
|||
if reply == QMessageBox.Yes:
|
||||
self._db.delete_reminder(reminder.id)
|
||||
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
|
||||
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QVBoxLayout
|
||||
|
||||
from . import strings
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ from typing import Iterable, Tuple
|
|||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSizePolicy,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
||||
Row = Tuple[str, str]
|
||||
Row = Tuple[str, str, str, str, str | None]
|
||||
|
||||
|
||||
class Search(QWidget):
|
||||
|
|
@ -52,9 +52,27 @@ class Search(QWidget):
|
|||
lay.addWidget(self.results)
|
||||
|
||||
def _open_selected(self, item: QListWidgetItem):
|
||||
date_str = item.data(Qt.ItemDataRole.UserRole)
|
||||
if date_str:
|
||||
self.openDateRequested.emit(date_str)
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind == "page":
|
||||
date_iso = data.get("date")
|
||||
if date_iso:
|
||||
self.openDateRequested.emit(date_iso)
|
||||
elif kind == "document":
|
||||
doc_id = data.get("doc_id")
|
||||
file_name = data.get("file_name") or "document"
|
||||
if doc_id is None:
|
||||
return
|
||||
self._open_document(int(doc_id), file_name)
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open the selected document in the user's default app."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _search(self, text: str):
|
||||
"""
|
||||
|
|
@ -80,28 +98,28 @@ class Search(QWidget):
|
|||
self.resultDatesChanged.emit([]) # clear highlights
|
||||
return
|
||||
|
||||
self.resultDatesChanged.emit(sorted({d for d, _ in rows}))
|
||||
# Only highlight calendar dates for page results
|
||||
page_dates = sorted(
|
||||
{key for (kind, key, _title, _text, _aux) in rows if kind == "page"}
|
||||
)
|
||||
self.resultDatesChanged.emit(page_dates)
|
||||
self.results.show()
|
||||
|
||||
for date_str, content in rows:
|
||||
# Build an HTML fragment around the match and whether to show ellipses
|
||||
frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
|
||||
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||
for kind, key, title, text, aux in rows:
|
||||
# Build an HTML fragment around the match
|
||||
frag_html = self._make_html_snippet(text, query, radius=30, maxlen=90)
|
||||
|
||||
container = QWidget()
|
||||
outer = QVBoxLayout(container)
|
||||
outer.setContentsMargins(8, 6, 8, 6)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(2)
|
||||
|
||||
# Date label (plain text)
|
||||
date_lbl = QLabel()
|
||||
date_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||
date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
|
||||
date_f = date_lbl.font()
|
||||
date_f.setPointSizeF(date_f.pointSizeF() + 1)
|
||||
date_lbl.setFont(date_f)
|
||||
outer.addWidget(date_lbl)
|
||||
# ---- Heading (date for pages, "Document" for docs) ----
|
||||
heading = QLabel(title)
|
||||
heading.setStyleSheet("font-weight:bold;")
|
||||
outer.addWidget(heading)
|
||||
|
||||
# Preview row with optional ellipses
|
||||
# ---- Preview row ----
|
||||
row = QWidget()
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
|
|
@ -117,9 +135,9 @@ class Search(QWidget):
|
|||
else "<span style='color:#888'>(no preview)</span>"
|
||||
)
|
||||
h.addWidget(preview, 1)
|
||||
|
||||
outer.addWidget(row)
|
||||
|
||||
# Separator line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
|
|
@ -127,9 +145,22 @@ class Search(QWidget):
|
|||
|
||||
# ---- Add to list ----
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, date_str)
|
||||
item.setSizeHint(container.sizeHint())
|
||||
if kind == "page":
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"kind": "page", "date": key},
|
||||
)
|
||||
else: # document
|
||||
item.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{
|
||||
"kind": "document",
|
||||
"doc_id": int(key),
|
||||
"file_name": aux or "",
|
||||
},
|
||||
)
|
||||
|
||||
item.setSizeHint(container.sizeHint())
|
||||
self.results.addItem(item)
|
||||
self.results.setItemWidget(item, container)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QSettings, QStandardPaths
|
||||
|
||||
from .db import DBConfig
|
||||
|
|
@ -41,9 +42,16 @@ def load_db_config() -> DBConfig:
|
|||
idle = s.value("ui/idle_minutes", 15, type=int)
|
||||
theme = s.value("ui/theme", "system", type=str)
|
||||
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)
|
||||
time_log = s.value("ui/time_log", 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)
|
||||
invoicing = s.value("ui/invoicing", False, type=bool)
|
||||
locale = s.value("ui/locale", "en", type=str)
|
||||
font_size = s.value("ui/font_size", 11, type=int)
|
||||
return DBConfig(
|
||||
|
|
@ -52,9 +60,14 @@ def load_db_config() -> DBConfig:
|
|||
idle_minutes=idle,
|
||||
theme=theme,
|
||||
move_todos=move_todos,
|
||||
move_todos_include_weekends=move_todos_include_weekends,
|
||||
tags=tags,
|
||||
time_log=time_log,
|
||||
reminders=reminders,
|
||||
reminders_webhook_url=reminders_webhook_url,
|
||||
reminders_webhook_secret=reminders_webhook_secret,
|
||||
documents=documents,
|
||||
invoicing=invoicing,
|
||||
locale=locale,
|
||||
font_size=font_size,
|
||||
)
|
||||
|
|
@ -67,8 +80,13 @@ def save_db_config(cfg: DBConfig) -> None:
|
|||
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
||||
s.setValue("ui/theme", str(cfg.theme))
|
||||
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/time_log", str(cfg.time_log))
|
||||
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/invoicing", str(cfg.invoicing))
|
||||
s.setValue("ui/locale", str(cfg.locale))
|
||||
s.setValue("ui/font_size", str(cfg.font_size))
|
||||
|
|
|
|||
|
|
@ -2,34 +2,37 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QDialogButtonBox,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
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 .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):
|
||||
|
|
@ -42,7 +45,7 @@ class SettingsDialog(QDialog):
|
|||
|
||||
self.current_settings = load_db_config()
|
||||
|
||||
self.setMinimumWidth(480)
|
||||
self.setMinimumWidth(600)
|
||||
self.setSizeGripEnabled(True)
|
||||
|
||||
# --- Tabs ----------------------------------------------------------
|
||||
|
|
@ -166,6 +169,25 @@ class SettingsDialog(QDialog):
|
|||
self.move_todos.setCursor(Qt.PointingHandCursor)
|
||||
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.setChecked(self.current_settings.tags)
|
||||
self.tags.setCursor(Qt.PointingHandCursor)
|
||||
|
|
@ -176,12 +198,145 @@ class SettingsDialog(QDialog):
|
|||
self.time_log.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.time_log)
|
||||
|
||||
self.invoicing = QCheckBox(strings._("enable_invoicing_feature"))
|
||||
invoicing_enabled = getattr(self.current_settings, "invoicing", False)
|
||||
self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log)
|
||||
self.invoicing.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.invoicing)
|
||||
# Invoicing only if time_log is enabled
|
||||
if not self.current_settings.time_log:
|
||||
self.invoicing.setChecked(False)
|
||||
self.invoicing.setEnabled(False)
|
||||
self.time_log.toggled.connect(self._on_time_log_toggled)
|
||||
|
||||
# --- Reminders feature + webhook options -------------------------
|
||||
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
|
||||
self.reminders.setChecked(self.current_settings.reminders)
|
||||
self.reminders.toggled.connect(self._on_reminders_toggled)
|
||||
self.reminders.setCursor(Qt.PointingHandCursor)
|
||||
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.setChecked(self.current_settings.documents)
|
||||
self.documents.setCursor(Qt.PointingHandCursor)
|
||||
features_layout.addWidget(self.documents)
|
||||
|
||||
layout.addWidget(features_group)
|
||||
|
||||
# --- Invoicing / company profile section -------------------------
|
||||
self.invoicing_group = QGroupBox(strings._("invoice_company_profile"))
|
||||
invoicing_layout = QFormLayout(self.invoicing_group)
|
||||
|
||||
profile = self._db.get_company_profile() or (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
name, address, phone, email, tax_id, payment_details, logo_bytes = profile
|
||||
|
||||
self.company_name_edit = QLineEdit(name or "")
|
||||
self.company_address_edit = QTextEdit(address or "")
|
||||
self.company_phone_edit = QLineEdit(phone or "")
|
||||
self.company_email_edit = QLineEdit(email or "")
|
||||
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
||||
self.company_payment_details_edit = QTextEdit()
|
||||
self.company_payment_details_edit.setPlainText(payment_details or "")
|
||||
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_name") + ":", self.company_name_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_address") + ":", self.company_address_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_phone") + ":", self.company_phone_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_email") + ":", self.company_email_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
||||
)
|
||||
invoicing_layout.addRow(
|
||||
strings._("invoice_company_payment_details") + ":",
|
||||
self.company_payment_details_edit,
|
||||
)
|
||||
|
||||
# Logo picker - store bytes on self._logo_bytes
|
||||
self._logo_bytes = logo_bytes
|
||||
logo_row = QHBoxLayout()
|
||||
self.logo_label = QLabel(strings._("invoice_company_logo_not_set"))
|
||||
if logo_bytes:
|
||||
self.logo_label.setText(strings._("invoice_company_logo_set"))
|
||||
logo_btn = QPushButton(strings._("invoice_company_logo_choose"))
|
||||
logo_btn.clicked.connect(self._on_choose_logo)
|
||||
logo_row.addWidget(self.logo_label)
|
||||
logo_row.addWidget(logo_btn)
|
||||
invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row)
|
||||
|
||||
# Show/hide this whole block based on invoicing checkbox
|
||||
self.invoicing_group.setVisible(self.invoicing.isChecked())
|
||||
self.invoicing.toggled.connect(self.invoicing_group.setVisible)
|
||||
|
||||
layout.addWidget(self.invoicing_group)
|
||||
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
|
|
@ -305,17 +460,92 @@ class SettingsDialog(QDialog):
|
|||
idle_minutes=self.idle_spin.value(),
|
||||
theme=selected_theme.value,
|
||||
move_todos=self.move_todos.isChecked(),
|
||||
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
|
||||
tags=self.tags.isChecked(),
|
||||
time_log=self.time_log.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(),
|
||||
invoicing=(
|
||||
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
||||
),
|
||||
locale=self.locale_combobox.currentText(),
|
||||
font_size=self.font_size.value(),
|
||||
)
|
||||
|
||||
save_db_config(self._cfg)
|
||||
|
||||
# Save company profile only if invoicing is enabled
|
||||
if self.invoicing.isChecked() and self.time_log.isChecked():
|
||||
self._db.save_company_profile(
|
||||
name=self.company_name_edit.text().strip() or None,
|
||||
address=self.company_address_edit.toPlainText().strip() or None,
|
||||
phone=self.company_phone_edit.text().strip() or None,
|
||||
email=self.company_email_edit.text().strip() or None,
|
||||
tax_id=self.company_tax_id_edit.text().strip() or None,
|
||||
payment_details=self.company_payment_details_edit.toPlainText().strip()
|
||||
or None,
|
||||
logo=getattr(self, "_logo_bytes", None),
|
||||
)
|
||||
|
||||
self.parent().themes.set(selected_theme)
|
||||
self.accept()
|
||||
|
||||
def _on_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):
|
||||
p1 = KeyPrompt(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -3,24 +3,24 @@ from __future__ import annotations
|
|||
import datetime as _dt
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QWidget,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
|
||||
from .settings import load_db_config
|
||||
|
||||
# ---------- Activity heatmap ----------
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ class DateHeatmap(QWidget):
|
|||
fm = painter.fontMetrics()
|
||||
|
||||
# --- weekday labels on left -------------------------------------
|
||||
# Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
|
||||
# Python's weekday(): Monday=0 ... Sunday=6
|
||||
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
|
||||
|
||||
for dow in range(7):
|
||||
|
|
@ -215,7 +215,7 @@ class DateHeatmap(QWidget):
|
|||
col = int((x - self._margin_left) // cell_span) # week index
|
||||
row = int((y - self._margin_top) // cell_span) # dow (0..6)
|
||||
|
||||
# Only 7 rows (Mon–Sun)
|
||||
# Only 7 rows (Mon-Sun)
|
||||
if not (0 <= row < 7):
|
||||
return
|
||||
|
||||
|
|
@ -248,8 +248,9 @@ class StatisticsDialog(QDialog):
|
|||
self._db = db
|
||||
|
||||
self.setWindowTitle(strings._("statistics"))
|
||||
self.setMinimumWidth(600)
|
||||
self.setMinimumHeight(400)
|
||||
self.setMinimumWidth(650)
|
||||
self.setMinimumHeight(650)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
(
|
||||
|
|
@ -263,50 +264,212 @@ class StatisticsDialog(QDialog):
|
|||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
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()
|
||||
|
||||
# --- Numeric summary at the top ----------------------------------
|
||||
form = QFormLayout()
|
||||
root.addLayout(form)
|
||||
self.cfg = load_db_config()
|
||||
|
||||
form.addRow(
|
||||
# Optional: per-date document counts for the heatmap.
|
||||
documents_by_date: Dict[_dt.date, int] = {}
|
||||
total_documents = 0
|
||||
date_most_documents: _dt.date | None = None
|
||||
date_most_documents_count = 0
|
||||
|
||||
if self.cfg.documents:
|
||||
try:
|
||||
documents_by_date = self._db.documents_by_date() or {}
|
||||
except Exception:
|
||||
documents_by_date = {}
|
||||
|
||||
if documents_by_date:
|
||||
total_documents = sum(documents_by_date.values())
|
||||
# Choose the date with the highest count, tie-breaking by earliest date.
|
||||
date_most_documents, date_most_documents_count = sorted(
|
||||
documents_by_date.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[0]
|
||||
|
||||
# For the heatmap
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Feature groups
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# --- Pages / words / revisions -----------------------------------
|
||||
pages_group = QGroupBox(strings._("stats_group_pages"))
|
||||
pages_form = QFormLayout(pages_group)
|
||||
|
||||
pages_form.addRow(
|
||||
strings._("stats_pages_with_content"),
|
||||
QLabel(str(pages_with_content)),
|
||||
)
|
||||
form.addRow(
|
||||
pages_form.addRow(
|
||||
strings._("stats_total_revisions"),
|
||||
QLabel(str(total_revisions)),
|
||||
)
|
||||
|
||||
if page_most_revisions:
|
||||
form.addRow(
|
||||
pages_form.addRow(
|
||||
strings._("stats_page_most_revisions"),
|
||||
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
|
||||
)
|
||||
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"),
|
||||
QLabel(str(total_words)),
|
||||
)
|
||||
|
||||
# Unique tag names
|
||||
form.addRow(
|
||||
strings._("stats_unique_tags"),
|
||||
QLabel(str(unique_tags)),
|
||||
)
|
||||
root.addWidget(pages_group)
|
||||
|
||||
if page_most_tags:
|
||||
form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
||||
# --- Tags ---------------------------------------------------------
|
||||
if self.cfg.tags:
|
||||
tags_group = QGroupBox(strings._("stats_group_tags"))
|
||||
tags_form = QFormLayout(tags_group)
|
||||
|
||||
tags_form.addRow(
|
||||
strings._("stats_unique_tags"),
|
||||
QLabel(str(unique_tags)),
|
||||
)
|
||||
else:
|
||||
form.addRow(strings._("stats_page_most_tags"), QLabel("—"))
|
||||
|
||||
# --- Heatmap with switcher ---------------------------------------
|
||||
if words_by_date or revisions_by_date:
|
||||
if page_most_tags:
|
||||
tags_form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
|
||||
)
|
||||
else:
|
||||
tags_form.addRow(
|
||||
strings._("stats_page_most_tags"),
|
||||
QLabel("—"),
|
||||
)
|
||||
|
||||
root.addWidget(tags_group)
|
||||
|
||||
# --- Documents ----------------------------------------------------
|
||||
if self.cfg.documents:
|
||||
docs_group = QGroupBox(strings._("stats_group_documents"))
|
||||
docs_form = QFormLayout(docs_group)
|
||||
|
||||
docs_form.addRow(
|
||||
strings._("stats_total_documents"),
|
||||
QLabel(str(total_documents)),
|
||||
)
|
||||
|
||||
if date_most_documents:
|
||||
doc_most_label = (
|
||||
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
|
||||
)
|
||||
else:
|
||||
doc_most_label = "—"
|
||||
|
||||
docs_form.addRow(
|
||||
strings._("stats_date_most_documents"),
|
||||
QLabel(doc_most_label),
|
||||
)
|
||||
|
||||
root.addWidget(docs_group)
|
||||
|
||||
# --- 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_layout = QVBoxLayout(group)
|
||||
|
||||
|
|
@ -315,14 +478,30 @@ class StatisticsDialog(QDialog):
|
|||
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
|
||||
self.metric_combo = QComboBox()
|
||||
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:
|
||||
self.metric_combo.addItem(
|
||||
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.addStretch(1)
|
||||
group_layout.addLayout(combo_row)
|
||||
|
||||
self._heatmap = DateHeatmap()
|
||||
self._words_by_date = words_by_date
|
||||
self._revisions_by_date = revisions_by_date
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
|
|
@ -339,11 +518,19 @@ class StatisticsDialog(QDialog):
|
|||
else:
|
||||
root.addWidget(QLabel(strings._("stats_no_data")))
|
||||
|
||||
self.resize(self.sizeHint().width(), self.sizeHint().height())
|
||||
|
||||
# ---------- internal helpers ----------
|
||||
|
||||
def _apply_metric(self, metric: str) -> None:
|
||||
if metric == "revisions":
|
||||
self._heatmap.set_data(self._revisions_by_date)
|
||||
elif metric == "documents":
|
||||
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:
|
||||
self._heatmap.set_data(self._words_by_date)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from importlib.resources import files
|
||||
import json
|
||||
from importlib.resources import files
|
||||
|
||||
# Get list of locales
|
||||
root = files("bouquin") / "locales"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QColorDialog,
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .db import DBManager
|
||||
from . import strings
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
from . import strings
|
||||
from .db import DBManager
|
||||
from .settings import load_db_config
|
||||
|
||||
|
||||
class TagBrowserDialog(QDialog):
|
||||
openDateRequested = Signal(str)
|
||||
|
|
@ -25,6 +26,7 @@ class TagBrowserDialog(QDialog):
|
|||
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self.cfg = load_db_config()
|
||||
self.setWindowTitle(
|
||||
strings._("tag_browser_title") + " / " + strings._("manage_tags")
|
||||
)
|
||||
|
|
@ -38,9 +40,18 @@ class TagBrowserDialog(QDialog):
|
|||
layout.addWidget(instructions)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderLabels(
|
||||
[strings._("tag"), strings._("color_hex"), strings._("date")]
|
||||
)
|
||||
if not self.cfg.documents:
|
||||
self.tree.setHeaderLabels(
|
||||
[strings._("tag"), strings._("color_hex"), strings._("date")]
|
||||
)
|
||||
else:
|
||||
self.tree.setHeaderLabels(
|
||||
[
|
||||
strings._("tag"),
|
||||
strings._("color_hex"),
|
||||
strings._("page_or_document"),
|
||||
]
|
||||
)
|
||||
self.tree.setColumnWidth(0, 200)
|
||||
self.tree.setColumnWidth(1, 100)
|
||||
self.tree.itemActivated.connect(self._on_item_activated)
|
||||
|
|
@ -119,6 +130,7 @@ class TagBrowserDialog(QDialog):
|
|||
|
||||
self.tree.addTopLevelItem(root)
|
||||
|
||||
# Pages with this tag
|
||||
pages = self._db.get_pages_for_tag(name)
|
||||
for date_iso, _content in pages:
|
||||
child = QTreeWidgetItem(["", "", date_iso])
|
||||
|
|
@ -127,6 +139,21 @@ class TagBrowserDialog(QDialog):
|
|||
)
|
||||
root.addChild(child)
|
||||
|
||||
# Documents with this tag
|
||||
if self.cfg.documents:
|
||||
docs = self._db.get_documents_for_tag(name)
|
||||
for doc_id, project_name, file_name in docs:
|
||||
label = file_name
|
||||
if project_name:
|
||||
label = f"{file_name} ({project_name})"
|
||||
child = QTreeWidgetItem(["", "", label])
|
||||
child.setData(
|
||||
0,
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"type": "document", "id": doc_id},
|
||||
)
|
||||
root.addChild(child)
|
||||
|
||||
if focus_tag and name.lower() == focus_tag.lower():
|
||||
focus_item = root
|
||||
|
||||
|
|
@ -153,12 +180,25 @@ class TagBrowserDialog(QDialog):
|
|||
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(data, dict):
|
||||
if data.get("type") == "page":
|
||||
item_type = data.get("type")
|
||||
|
||||
if item_type == "page":
|
||||
date_iso = data.get("date")
|
||||
if date_iso:
|
||||
self.openDateRequested.emit(date_iso)
|
||||
self.accept()
|
||||
|
||||
elif item_type == "document":
|
||||
doc_id = data.get("id")
|
||||
if doc_id is not None:
|
||||
self._open_document(int(doc_id), str(data.get("file_name")))
|
||||
|
||||
def _open_document(self, doc_id: int, file_name: str) -> None:
|
||||
"""Open a tagged document from the list."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||
|
||||
def _add_a_tag(self):
|
||||
"""Add a new tag"""
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ from typing import Optional
|
|||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCompleter,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QToolButton,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QStyle,
|
||||
QCompleter,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import strings
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from PySide6.QtGui import QPalette, QColor, QGuiApplication
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from weakref import WeakSet
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QGuiApplication, QPalette, QTextCharFormat
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
|
||||
class Theme(Enum):
|
||||
SYSTEM = "system"
|
||||
|
|
@ -174,6 +176,14 @@ class ThemeManager(QObject):
|
|||
cal.setPalette(app_pal)
|
||||
cal.setStyleSheet("")
|
||||
|
||||
# --- Normalise weekend colours on *all* themed calendars -------------
|
||||
# Qt's default is red for weekends; we want them to match normal text.
|
||||
weekday_color = app_pal.windowText().color()
|
||||
weekend_fmt = QTextCharFormat()
|
||||
weekend_fmt.setForeground(weekday_color)
|
||||
cal.setWeekdayTextFormat(Qt.Saturday, weekend_fmt)
|
||||
cal.setWeekdayTextFormat(Qt.Sunday, weekend_fmt)
|
||||
|
||||
cal.update()
|
||||
|
||||
def _calendar_qss(self, highlight_css: str) -> str:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence
|
||||
from PySide6.QtWidgets import QToolBar
|
||||
|
||||
from . import strings
|
||||
|
|
@ -20,6 +20,7 @@ class ToolBar(QToolBar):
|
|||
insertImageRequested = Signal()
|
||||
alarmRequested = Signal()
|
||||
timerRequested = Signal()
|
||||
documentsRequested = Signal()
|
||||
fontSizeLargerRequested = Signal()
|
||||
fontSizeSmallerRequested = Signal()
|
||||
|
||||
|
|
@ -118,8 +119,14 @@ class ToolBar(QToolBar):
|
|||
# Focus timer
|
||||
self.actTimer = QAction("⌛", self)
|
||||
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
|
||||
self.actTimer.setCheckable(True)
|
||||
self.actTimer.triggered.connect(self.timerRequested)
|
||||
|
||||
# Documents
|
||||
self.actDocuments = QAction("📁", self)
|
||||
self.actDocuments.setToolTip(strings._("toolbar_documents"))
|
||||
self.actDocuments.triggered.connect(self.documentsRequested)
|
||||
|
||||
# Set exclusive buttons in QActionGroups
|
||||
self.grpHeadings = QActionGroup(self)
|
||||
self.grpHeadings.setExclusive(True)
|
||||
|
|
@ -159,6 +166,7 @@ class ToolBar(QToolBar):
|
|||
self.actInsertImg,
|
||||
self.actAlarm,
|
||||
self.actTimer,
|
||||
self.actDocuments,
|
||||
self.actHistory,
|
||||
]
|
||||
)
|
||||
|
|
@ -185,6 +193,7 @@ class ToolBar(QToolBar):
|
|||
self._style_letter_button(self.actCheckboxes, "☑")
|
||||
self._style_letter_button(self.actAlarm, "⏰")
|
||||
self._style_letter_button(self.actTimer, "⌛")
|
||||
self._style_letter_button(self.actDocuments, "📁")
|
||||
|
||||
# History
|
||||
self._style_letter_button(self.actHistory, "↺")
|
||||
|
|
|
|||
|
|
@ -5,23 +5,17 @@ import os
|
|||
import re
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from importlib.resources import files
|
||||
from PySide6.QtCore import QStandardPaths, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QProgressDialog,
|
||||
)
|
||||
from PySide6.QtGui import QPixmap, QImage, QPainter, QGuiApplication
|
||||
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
|
||||
|
||||
from .settings import APP_NAME
|
||||
from . import strings
|
||||
|
||||
from .settings import APP_NAME
|
||||
|
||||
# Where to fetch the latest version string from
|
||||
VERSION_URL = "https://mig5.net/bouquin/version.txt"
|
||||
|
|
|
|||
522
poetry.lock
generated
522
poetry.lock
generated
|
|
@ -146,103 +146,103 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.12.0"
|
||||
version = "7.13.0"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"},
|
||||
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"},
|
||||
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"},
|
||||
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"},
|
||||
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"},
|
||||
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"},
|
||||
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"},
|
||||
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"},
|
||||
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"},
|
||||
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
|
||||
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
|
||||
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
|
||||
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
|
||||
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
|
||||
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
|
||||
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
|
||||
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
|
||||
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -267,13 +267,13 @@ xdg-desktop-portal = ["jeepney"]
|
|||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
||||
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
|
||||
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -380,57 +380,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
|
|||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"},
|
||||
{file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"},
|
||||
{file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Addons = "6.10.0"
|
||||
PySide6_Essentials = "6.10.0"
|
||||
shiboken6 = "6.10.0"
|
||||
PySide6_Addons = "6.10.1"
|
||||
PySide6_Essentials = "6.10.1"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-addons"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"},
|
||||
{file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"},
|
||||
{file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Essentials = "6.10.0"
|
||||
shiboken6 = "6.10.0"
|
||||
PySide6_Essentials = "6.10.1"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"},
|
||||
{file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"},
|
||||
{file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
shiboken6 = "6.10.0"
|
||||
shiboken6 = "6.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
|
|
@ -534,147 +534,153 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
|||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.10.0"
|
||||
version = "6.10.1"
|
||||
description = "Python/C++ bindings helper module"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.9"
|
||||
python-versions = "<3.15,>=3.9"
|
||||
files = [
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"},
|
||||
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"},
|
||||
{file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlcipher3-wheels"
|
||||
version = "0.5.5.post0"
|
||||
version = "0.5.6"
|
||||
description = "DB-API 2.0 interface for SQLCipher 3.x"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"},
|
||||
{file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e16c8caf59e86589fb5f52253420db07121f1f96e2a12e244f6fdcaf8b946530"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:337f2e059114729dd1529ee356c98e2aa06440d6a9772917514a3bda0647c61c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f6bd900658446e1cdeebda0760adb9a89f55888b460623db88b100845cb51bc2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dc6fcca569858145cb5ba3c878997d1788973e36f689090178f807b9a44d9ca6"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:eef50cc39554ad1fb82faa33d25c7f3cb11e2f7087b41109bc169db2c942f0c7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:0fc36fc67f639a0e03cf6f7c6a5d1bc5cdd8005e8e07da3b21c54d4d81ed353b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:53d0b861668d6847c7cc0dc7b443263b95a5cd211bcc326a457bd3122ebbb5a0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:10aef293397a4ab25d8346ba5f96181214ab9c6a8836d83320cf23a2ad773a2c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1105e7edba36a29625a824bff0eca3685c1cf6e391182b85a9a73b4b1604eef3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5db9b4035e42a27672abbe75120908c74a235a496cd92b4c685fda1e95e9b19c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f9e3fb5e96c5067a8cfd7b2fa7d939e529e30439058bbc15d0e9adca5e4cff1b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6f3c1a8a4a2c04225f5159cf7f1c315101a89271afbaef4205c6fc50766c5535"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fc0504a1dbe6d478614ef55eb80d0c02ead24bc91f34b41c07d404452389f42d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win32.whl", hash = "sha256:05ef2b35f176e3b29092ec9aa03b09f4803feddbabdc2174e7ccc608758f2beb"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:0f6873e4badf64eb8c5771c9e8a726df46ac663bc8051dfefb51fe2a46358b37"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_arm64.whl", hash = "sha256:9fd30c1cffa10f63f504a33494564efc0e0a475bbf069487016a9d2462d115e5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6c511bacd40ba769368b1abbf97fbefb285f525e6d2a399a704c22ba2aae37f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa25610cda2b2a1b1cefddbd93488e939cf0059480f2fda5a8704acddd0e8935"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5258fb99e99b6fda6f011a0a4094ff99fe2e9b9ac7ce81cf646e0e779829a3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459836d52904fa006bf36e2144959bd21577c32947fdd173db50b037108a8620"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:5b36f9949f4d35c72f0626aaac109b17688c1d6a9a6e11de2538b4cfc32cfad0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:87301b545556a1811780bb6fc6480ab1f2640d1d5b5e5e33ed404559ae383647"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:fcc4705b5b7bd3508d08a6389a45e14591071a3e575c2864c9c1c615df89e0da"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0a231eb677a8246c47e423c710198631850c0a090e8f02a7fb1ad266ba517c56"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71ef871c65ad7c61048acb4f57da29bc0d5e35874183006222c229b5f1f64c73"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3480298c9bc4117207535636fe74b01b4860ecd74a028c73b42f5f0ddaa8661"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d48cf218ed13f17e3037564f08fba7ddf2c260dac7993e3d4ac58ee30483f115"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ff57a80904b9bd55e18774cb59bffacad06e196298381ee576ce683d1c09b032"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50978685717cd9293ff5508c192695a894879f9faed5142d0e8d7b63310f87c2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win32.whl", hash = "sha256:24207dbb699ca68fc5fc7248385fdf33a92fb1e17a6ea88d3cf2345a18fb29ff"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:40b1f8188a0aa5bbec354a12561b014b43a6a0d0a0d230a8a9378ed7b826b0ec"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:107ef02bbd0f2ffb39a564c14ebf3bedfa4569949a0d72ec8e106f754d715b7c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:59a572b18d1ef8318e9f583a7b3e1a67b4b04ed4b783c3f29fa806635274d12a"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32dfb8903b24db5879b1f922114f650bc6a15df9d071c55eefeb6937e13b2d20"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f5770257736c43cbf910a22f74c1490ef1ecde0432e475904f038e64ffdacb0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c33f99ddfe08c0f34807046800e510316b8bac2974b3c5fb9ecb1ee25c391ac8"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:97d4c000deeb72c2421f555f3e55a8c161ddfb0499caabf60df2bfde6460a5fc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:67d9889028b4adfcaecd32e1e60330e1764c209ad12438f0eec2a5145ebf4a2d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:00cf178b15da486ab43ee2bed41edb1b393c5cfe2a48cae68893a2b31260dbd3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:95bfa4c5ffdd72d9d8676c913d585b7885a42824824cf1d9e93d3669f01492dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:030ab50a8f4153cfe8dd5c98724909b210243af2350b9c79914838905a99518e"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dc3c3d9deea654f8ea9c1dbc7bc90561331e4da9c7055381fac6498ca7267a3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cc986e8aa89e5a4a30b4eb8fd841d913a4e22ada99ec42be83f69bde3d86a31"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a41f0d30fa63d8db915566ec6987e68f064d96052cd6492ed8384b3e4807e60b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fefe8a41e68334c545465813782fd45ef5cfe1082d012d95514c8a78e8015"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win32.whl", hash = "sha256:ac2332f44758794a2fa19c77b824853e2a57ce5c27cc71c61066a52845be22d0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:6f016ba5a2a531938f332a234865dfc25d3a69abc169c3bf1d5c06c3c3f24601"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:101ce0f7403801b6988d1f6c94244900e0f6c5378666e0ffd74b300687a6f9ef"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:94527fa3994c0fa1275c23d9fbb02512aacc675f1e45f566c660f4f9d5376e75"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0920a4b24362522ba83b36a47495d174221361213207191c325749a621fabeca"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5061b07b121ebd76aa697755b1b8f642cc3a27a0f6d392180ab249b35f1c2394"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:79de8511bb1fec62128e1b366cdc0cbd2ad1d725f3e29f9c91e96946a3c67945"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4b92c2f35bb8153cc20bcfc651536f51cc1194403782c542a852497ac789cbe2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2d55211e3d2addff8a2df7335927d7fe6d75aa9ed12b396a22a5a0bfe2773ed9"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8cb31de5d67799cc2bba92f23adc10281d66c2c16ca6418b94d80500a164aa60"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:123796de3e471db5ed8b4ee4f97ec562ad38347ad678dad71133eade280202e0"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6d34fabacfad4f301a22b5d8466d7ee3481f735bdb327d8756f04c81d3516c4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:91b02fc765485c5b65f2a3eacfd2e16059253e007d0b5a5f24bba5fcea9032dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13db7f23c553ffdd35f6e3b26415bdb9f100dcf89038873965caef769e8f1af5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4ba79a81cd591d32a3a225e3e9b50a9871324d0e414fb6d0866049d8820e4e46"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f97be07997681ca90fb339d5411fcb957bd7cbe810389404baed207cb366badd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win32.whl", hash = "sha256:9e56e0a7aa778da3d46323fc1233da5dcede795a6c7fe4c11980fec0ce8c3fe3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:744845e4aa3cc614590f967aa1d38cc5d549177a2a83ed68c1821b5fb0505f8a"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:c92de0b940533ca3a5b43a45d0768e0698b6ca95020b2fd47ec269b6bfc228d1"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a3f558df797aabf51680b3fbce48c4b3df89c36ad7fcaa3886b2ed8057aa2786"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7e216586720663960c82f046c495ef6d828e8e95c8fcf4c767b555fb9b8feead"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e4ef70d3af8ebe6ababe8eff93b8bd4ad288d0a38ab29a2420c91d636fbfe14"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:11e34aac6cb7e29d23e339c5de9e87700ddf09886e104640578b5afb566a2c50"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:79e220312a075546e6be0a6062dda6315857b1478d78f97eb352f1383dde8ce2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b953af7b57867bcffeeab59681921671615ae4b42fd0a9234ad0be7e0e43dfd4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:130ac318dbcb3a51a4377b0bf3e450c6c21d508a8b00d2d9d4b3ee6a46ab3595"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5154c8022e58722987522ddce30f19fb69d6f8f6314959100d9f37c3dc5cba5b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f91d1f5b7b927aa00a8d83724c58875d9d0e47bd81ca40445090ab521b5fa"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e1c140bfa6b0a7e08f414f2a9f8f529f7d8c4cfa8386ce588e6c747c4ccc6615"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:18fc56dfb32c6ce370d929897205027f78275c32446d6b1be712d462789ae8c2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c03ec5e058fbf3fd94ecd8e0448834e8e7f46418eaec5fe5c7a0982c6e62c13f"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08756c1b25aebabb25a55dfe6f323876caea0c69511e34553807ae1d7ab843dd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win32.whl", hash = "sha256:bdbc58d224d27c002aed8a6361b43f3651943ecbfac69cd2674bbe681cf83790"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:dcc313f4519922c1ec3406b010d53f700750c1cf5331b9633a3c8b196307e852"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:dc1f0c77cc0395680176913a1d634a4014a1ebf02e7a7b2ac03a180b44241842"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fc30e82d2b8f139ac1ab81a3b3d9a59da8e3ce3b1e753285727480667efd5417"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f11d1d2c41141dd95f7d45f03dbe9f69a6427463e69db50609d83c0cd29980b5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92beff11fd9683941de7b47b8fc280e834b135ba7966d139b0ce2159b551ebad"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3060403647df7d44844c2808a384e4c4cf4a2a1b65e509a8016aca971c08ad39"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:9380de7e8fc952f376c9dae9ba1cdbb6a24ff5e41fd8f3b3cf39f1e305ed3248"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:9a26be381b0fb1c8d4fcdfd48182c78217ae9458513e4fe51b5045d4f94d41cb"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:c3be08f8d81372a6d084062f969f88be0b942ac449b0ac01825b853c12705421"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c5bd4abbebc15f8a2a9a653500cd1abeb3aac13887fcc83de31ca40fce32e3a2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4bb3c2c8e9a1e16455b989b2c7598b8053029bcbb519dc22601fa82bc8896f89"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:aac8ca9d2b4e18637e61ea1d8193500a1186f0b113b9224dc74186190f41c8e7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f237a41c3f08e69f2532aec29a2589097baa73886164537d90c744d3d2eb3b3"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e6e59c3e0301cb04351b1cb12231aaadb40f56f779fb50a7857c6b4ed4c57297"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba2296a608081f4474f4447658a1e032d0b5506153baf68233471afde1463da9"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win32.whl", hash = "sha256:8c8edfbd38a49ebbec2d1d56a000a499da2ac80b00488c156a1e0b8a7b8c10c6"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:21df85bc14d5d86225c1e7466ff65cbcc10f0d1d4f466823b4534c4c0564554c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_arm64.whl", hash = "sha256:64df3e807fb0e6d89c1e90ce7c900bb82b695c474e1a0945a5f92862cac8b63d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3784b22a29e4a675b456ca6ff1841d61e0eb97a28d0ba23d3d8cb5fe6da88238"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e27881be24f03d8a67a6db763f5671aaa05205de2380b1793b5e20bdabe49fba"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:667b6eec50ed03111676a0f4565be133643c9ad8bc88e6eea1c96b2af590c417"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4eaaa5cf77b125e05908b1200681e2988b1a6a307c7e677967053a1e4b07fba5"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:4ead5b8f2607718548c8571e4a89fe735dd53443a2b5e42d8147eecd11b0d94b"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_ppc64le.whl", hash = "sha256:d82a8a7b478d23368320ad185533d063ec14d11a1d188f07ace513a66bfa9580"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_s390x.whl", hash = "sha256:39d871ee8c13d9b0326b24a02e5af21a7b1c8fb5e6f6f4ec62b935392202ec69"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:5a8737683621c2917a4ee9ff774e597a368c5b3d23f08ae53897d6bd1f8bfc0e"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:08b6922d5020384fa641c8dc416f6f2b143110c86dcf3aae086e7ce15b192eae"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9dcc7f830ec56c090884a83be265c51c0a4fd60bb033b000c69c3bee08d77d8"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:0848f628b1528dd6a19a36679d8cde4b6f1f8d288757ba2e3df5578b79d79e90"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1476bb15586ce27ea5fae7c54469b2be4efe51ca9cefa20871a6c394a18892cc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:de17d373d9e7807236013950f598bf59b9ed7c375938fdb95378a7114e55ff95"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win32.whl", hash = "sha256:02fa9e7f98a8e9be871219014b9ac015ba630b51615d90a2c06d45547a4b0cf1"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win_amd64.whl", hash = "sha256:6b2d7daab225c578aec8109fde99624f281b4ccdc6c53c8cd8feb86d8e7d3cf2"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:abef5e28b4d1ca518291a8ca27af1cf9e4d68dd4a264d83874ec4d0a69589395"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd4c12a5a60cbd533ba4a3b4131d23302283ba597739c7867066b4efefe178db"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b7672837f1b9a6a67e375b743d74371d0428ead79ff367591145d06f3711c96"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:61c33e2697b0d91f3cbe806104e1d5b93961d3ab55ba55ee53bb36efe83c9933"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:2e6eb09782dd719a1bb34af6e5ef25e5713c1f806231b472fcf64eb9288957af"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6469b756ced0293e74806db2f114e5307cd4b05a559e986d3cc0b2eeb1eb8153"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:b6492f9bcb9296ac2179b5c9f7e7f329449b580836c0e8e5cfc2f3fe9af3486c"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e4968d98917309463f02e4a48abebd95ed3d37968346f2693ed8a08e2fe9794"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:50214729697a1ee9e7603ba62b8ea46d78903ae1332caaa94fbaedde113944b7"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1ec9fd1dd5774d665903b8ba2e3e4f8ed72879dc42f6e9b2815040f0cb2d8ccd"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ced8ab30d205c8b6225b5703885576e629266767b091158731ec76c8c490bef4"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3c7242a267dd802fee273084a5707a95d02df4102afbea133c8f716234c7edcc"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6c239d15085af4b0f3433fa274c1fc37369509b99a7c035a359d5142a0536d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win32.whl", hash = "sha256:cc29963df04a73d8420a4d023ba016c9013d86378969d8a11fe2148477282936"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_amd64.whl", hash = "sha256:38cc7bb3a371c4a5fe7f4236a409e64f1286796d780833243f9e15ef852f159d"},
|
||||
{file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_arm64.whl", hash = "sha256:186e49af3ddb98d260b95d436eaf58f2125712c268c8475627129c1f80a68164"},
|
||||
{file = "sqlcipher3_wheels-0.5.6.tar.gz", hash = "sha256:1d232c14be44db95a7f3018433cae01ecd18803fa2468fce3cc45ebd5e034942"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -741,20 +747,20 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.2"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
|
||||
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "bouquin"
|
||||
version = "0.5.3"
|
||||
version = "0.7.3"
|
||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
readme = "README.md"
|
||||
|
|
|
|||
35
release.sh
35
release.sh
|
|
@ -2,18 +2,47 @@
|
|||
|
||||
set -eo pipefail
|
||||
|
||||
rm -rf dist
|
||||
# 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
|
||||
filedust -y .
|
||||
|
||||
# Publish to Pypi
|
||||
poetry build
|
||||
poetry publish
|
||||
|
||||
# Make AppImage
|
||||
sudo apt-get install libfuse-dev
|
||||
sudo apt-get -y install libfuse-dev
|
||||
poetry run pyproject-appimage
|
||||
mv Bouquin.AppImage dist/
|
||||
|
||||
# Sign packages
|
||||
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"
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 161 KiB |
|
|
@ -95,3 +95,28 @@ def _stub_code_block_editor_dialog(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog
|
||||
)
|
||||
|
||||
|
||||
# --- Freeze Qt time helper (for alarm parsing tests) ---
|
||||
@pytest.fixture
|
||||
def freeze_qt_time(monkeypatch):
|
||||
"""Freeze QDateTime.currentDateTime/QTime.currentTime to midday today.
|
||||
|
||||
This avoids flakiness when tests run close to midnight, so that
|
||||
QTime.currentTime().addSecs(3600) is still the same calendar day.
|
||||
"""
|
||||
import bouquin.main_window as _mwmod
|
||||
from PySide6.QtCore import QDate, QDateTime, QTime
|
||||
|
||||
today = QDate.currentDate()
|
||||
fixed_time = QTime(12, 0)
|
||||
fixed_dt = QDateTime(today, fixed_time)
|
||||
|
||||
# Patch the *imported* Qt symbols that main_window uses
|
||||
monkeypatch.setattr(
|
||||
_mwmod.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_mwmod.QTime, "currentTime", staticmethod(lambda: QTime(fixed_time))
|
||||
)
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import bouquin.bug_report_dialog as bugmod
|
||||
from bouquin.bug_report_dialog import BugReportDialog
|
||||
from bouquin import strings
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from bouquin.bug_report_dialog import BugReportDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
def test_bug_report_truncates_text_to_max_chars(qtbot):
|
||||
|
|
|
|||
325
tests/test_code_block_editor_dialog.py
Normal file
325
tests/test_code_block_editor_dialog.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
from bouquin import strings
|
||||
from bouquin.code_block_editor_dialog import (
|
||||
CodeBlockEditorDialog,
|
||||
CodeEditorWithLineNumbers,
|
||||
)
|
||||
from PySide6.QtCore import QRect, QSize
|
||||
from PySide6.QtGui import QFont, QPaintEvent
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
|
||||
def _find_button_by_text(widget, text):
|
||||
for btn in widget.findChildren(QPushButton):
|
||||
if text.lower() in btn.text().lower():
|
||||
return btn
|
||||
return None
|
||||
|
||||
|
||||
def test_code_block_dialog_delete_flow(qtbot):
|
||||
dlg = CodeBlockEditorDialog("print(1)", "python", allow_delete=True)
|
||||
qtbot.addWidget(dlg)
|
||||
delete_txt = strings._("delete_code_block")
|
||||
delete_btn = _find_button_by_text(dlg, delete_txt)
|
||||
assert delete_btn is not None
|
||||
assert not dlg.was_deleted()
|
||||
with qtbot.waitSignal(dlg.finished, timeout=2000):
|
||||
delete_btn.click()
|
||||
assert dlg.was_deleted()
|
||||
|
||||
|
||||
def test_code_block_dialog_language_and_code(qtbot):
|
||||
dlg = CodeBlockEditorDialog("x = 1", "not-a-lang", allow_delete=False)
|
||||
qtbot.addWidget(dlg)
|
||||
delete_txt = strings._("delete_code_block")
|
||||
assert _find_button_by_text(dlg, delete_txt) is None
|
||||
assert dlg.code() == "x = 1"
|
||||
assert dlg.language() is None
|
||||
|
||||
|
||||
def test_line_number_area_size_hint(qtbot, app):
|
||||
"""Test _LineNumberArea.sizeHint() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
line_area = editor._line_number_area
|
||||
size_hint = line_area.sizeHint()
|
||||
|
||||
# Should return a QSize with width from editor
|
||||
assert isinstance(size_hint, QSize)
|
||||
assert size_hint.width() > 0
|
||||
assert size_hint.height() == 0
|
||||
|
||||
|
||||
def test_line_number_area_paint_event(qtbot, app):
|
||||
"""Test _LineNumberArea.paintEvent() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2\nLine 3")
|
||||
editor.show()
|
||||
|
||||
# Trigger a paint event on the line number area
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(QRect(0, 0, line_area.width(), line_area.height()))
|
||||
line_area.paintEvent(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_line_number_font_pixel_size_fallback(qtbot, app):
|
||||
"""Test _line_number_font() with pixel-sized font."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set a pixel-sized font (pointSize will be -1)
|
||||
font = QFont()
|
||||
font.setPixelSize(14)
|
||||
editor.setFont(font)
|
||||
|
||||
# Get line number font - should use the fallback
|
||||
line_font = editor._line_number_font()
|
||||
|
||||
# Should have calculated a size
|
||||
assert line_font.pointSizeF() > 0 or line_font.pixelSize() > 0
|
||||
|
||||
|
||||
def test_code_editor_resize_event(qtbot, app):
|
||||
"""Test CodeEditorWithLineNumbers.resizeEvent() method."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
|
||||
# Resize the editor
|
||||
editor.resize(400, 300)
|
||||
|
||||
# Line number area should be repositioned
|
||||
line_area = editor._line_number_area
|
||||
assert line_area.geometry().width() > 0
|
||||
assert line_area.geometry().height() == editor.contentsRect().height()
|
||||
|
||||
|
||||
def test_code_editor_update_with_scroll(qtbot, app):
|
||||
"""Test _update_line_number_area with dy (scroll) parameter."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add enough text to enable scrolling
|
||||
text = "\n".join([f"Line {i}" for i in range(100)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Trigger update with scroll offset
|
||||
rect = QRect(0, 0, 100, 100)
|
||||
editor._update_line_number_area(rect, dy=10)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_update_without_scroll(qtbot, app):
|
||||
"""Test _update_line_number_area without scroll (dy=0)."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2")
|
||||
editor.show()
|
||||
|
||||
# Trigger update without scroll
|
||||
rect = QRect(0, 0, 100, 100)
|
||||
editor._update_line_number_area(rect, dy=0)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_update_contains_viewport(qtbot, app):
|
||||
"""Test _update_line_number_area when rect contains viewport."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Test")
|
||||
editor.show()
|
||||
|
||||
# Trigger update with rect that contains viewport
|
||||
viewport_rect = editor.viewport().rect()
|
||||
editor._update_line_number_area(viewport_rect, dy=0)
|
||||
|
||||
# Should trigger width update (covers line 82)
|
||||
|
||||
|
||||
def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
|
||||
"""Test line_number_area_paint_event with multiple text blocks."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add multiple lines
|
||||
text = "\n".join([f"Line {i}" for i in range(20)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Force a paint event
|
||||
line_area = editor._line_number_area
|
||||
rect = QRect(0, 0, line_area.width(), line_area.height())
|
||||
paint_event = QPaintEvent(rect)
|
||||
|
||||
# This should exercise the painting loop
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_line_number_area_paint_with_long_file(qtbot, app):
|
||||
"""Test line_number_area_paint_event with many lines."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Add 1000+ lines to test digit calculation and painting
|
||||
text = "\n".join([f"Line {i}" for i in range(1000)])
|
||||
editor.setPlainText(text)
|
||||
editor.show()
|
||||
|
||||
# Trigger paint event
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(line_area.rect())
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Line number width should accommodate 4 digits
|
||||
width = editor.line_number_area_width()
|
||||
assert width > 30 # Should be wider for 4-digit numbers
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_with_delete(qtbot, app):
|
||||
"""Test CodeBlockEditorDialog with allow_delete=True."""
|
||||
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=True)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should have delete button functionality
|
||||
assert hasattr(dialog, "_delete_requested")
|
||||
assert dialog._delete_requested is False
|
||||
|
||||
# Simulate delete click
|
||||
dialog._on_delete_clicked()
|
||||
|
||||
assert dialog._delete_requested is True
|
||||
assert dialog.was_deleted() is True
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_without_delete(qtbot, app):
|
||||
"""Test CodeBlockEditorDialog with allow_delete=False."""
|
||||
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=False)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should not have been deleted
|
||||
assert dialog.was_deleted() is False
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_language_selection(qtbot, app):
|
||||
"""Test language selection in dialog."""
|
||||
dialog = CodeBlockEditorDialog("test", "javascript")
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should have selected javascript
|
||||
assert dialog.language() == "javascript"
|
||||
|
||||
# Change language
|
||||
dialog._lang_combo.setCurrentText("python")
|
||||
assert dialog.language() == "python"
|
||||
|
||||
# Empty language
|
||||
dialog._lang_combo.setCurrentText("")
|
||||
assert dialog.language() is None
|
||||
|
||||
|
||||
def test_code_block_editor_dialog_code_retrieval(qtbot, app):
|
||||
"""Test getting code from dialog."""
|
||||
original_code = "def foo():\n pass"
|
||||
dialog = CodeBlockEditorDialog(original_code, None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should return the code
|
||||
assert dialog.code() == original_code
|
||||
|
||||
# Modify code
|
||||
new_code = "def bar():\n return 42"
|
||||
dialog._code_edit.setPlainText(new_code)
|
||||
assert dialog.code() == new_code
|
||||
|
||||
|
||||
def test_code_editor_with_empty_text(qtbot, app):
|
||||
"""Test editor with no text."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
|
||||
# Should still paint line numbers
|
||||
line_area = editor._line_number_area
|
||||
paint_event = QPaintEvent(line_area.rect())
|
||||
editor.line_number_area_paint_event(paint_event)
|
||||
|
||||
# Should not crash
|
||||
|
||||
|
||||
def test_code_editor_block_count_changed(qtbot, app):
|
||||
"""Test that block count changes trigger width updates."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
initial_width = editor.line_number_area_width()
|
||||
|
||||
# Add lots of lines (should require more digits)
|
||||
text = "\n".join([f"Line {i}" for i in range(1000)])
|
||||
editor.setPlainText(text)
|
||||
|
||||
new_width = editor.line_number_area_width()
|
||||
|
||||
# Width should increase for more digits
|
||||
assert new_width > initial_width
|
||||
|
||||
|
||||
def test_code_editor_cursor_position_changed(qtbot, app):
|
||||
"""Test that cursor position changes update line number area."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Line 1\nLine 2\nLine 3")
|
||||
editor.show()
|
||||
|
||||
# Move cursor
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(cursor.MoveOperation.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should trigger line number area update (via signal connection)
|
||||
# Just verify it doesn't crash
|
||||
|
||||
|
||||
def test_line_number_area_width_calculation(qtbot, app):
|
||||
"""Test line number area width calculation with various block counts."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Test with 1 line (should use minimum 2 digits)
|
||||
editor.setPlainText("One line")
|
||||
width_1 = editor.line_number_area_width()
|
||||
assert width_1 > 0
|
||||
|
||||
# Test with 10 lines (2 digits)
|
||||
editor.setPlainText("\n".join(["Line"] * 10))
|
||||
width_10 = editor.line_number_area_width()
|
||||
assert width_10 >= width_1
|
||||
|
||||
# Test with 100 lines (3 digits)
|
||||
editor.setPlainText("\n".join(["Line"] * 100))
|
||||
width_100 = editor.line_number_area_width()
|
||||
assert width_100 > width_10
|
||||
|
||||
|
||||
def test_code_editor_viewport_margins(qtbot, app):
|
||||
"""Test that viewport margins are set correctly."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("Test")
|
||||
editor.show()
|
||||
|
||||
# Left margin should equal line number area width
|
||||
margins = editor.viewportMargins()
|
||||
line_width = editor.line_number_area_width()
|
||||
|
||||
assert margins.left() == line_width
|
||||
assert margins.top() == 0
|
||||
assert margins.right() == 0
|
||||
assert margins.bottom() == 0
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
|
||||
from PySide6.QtGui import QTextCharFormat, QFont
|
||||
from bouquin.code_highlighter import CodeBlockMetadata, CodeHighlighter
|
||||
from PySide6.QtGui import QFont, QTextCharFormat
|
||||
|
||||
|
||||
def test_get_language_patterns_python(app):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import pytest
|
||||
import json, csv
|
||||
import csv
|
||||
import datetime as dt
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
from bouquin.db import DBManager
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from bouquin.db import DBManager
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
|
||||
|
||||
def _today():
|
||||
return dt.date.today().isoformat()
|
||||
|
|
@ -61,8 +63,10 @@ def test_dates_with_content_and_search(fresh_db):
|
|||
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
|
||||
|
||||
hits = list(fresh_db.search_entries("alpha"))
|
||||
assert any(d == _today() for d, _ in hits)
|
||||
assert any(d == _tomorrow() for d, _ in hits)
|
||||
# search_entries now returns (kind, key, title, text, aux)
|
||||
page_dates = [key for (kind, key, _title, _text, _aux) in hits if kind == "page"]
|
||||
assert _today() in page_dates
|
||||
assert _tomorrow() in page_dates
|
||||
|
||||
|
||||
def test_get_all_entries_and_export(fresh_db, tmp_path):
|
||||
|
|
@ -369,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db):
|
|||
"""Test gather_stats on empty database."""
|
||||
stats = fresh_db.gather_stats()
|
||||
|
||||
assert len(stats) == 10
|
||||
assert len(stats) == 22
|
||||
(
|
||||
pages_with_content,
|
||||
total_revisions,
|
||||
|
|
@ -381,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db):
|
|||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
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
|
||||
|
||||
assert pages_with_content == 0
|
||||
|
|
@ -417,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db):
|
|||
page_most_tags,
|
||||
page_most_tags_count,
|
||||
revisions_by_date,
|
||||
*_rest,
|
||||
) = stats
|
||||
|
||||
assert pages_with_content == 2
|
||||
|
|
@ -433,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")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
|
||||
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
|
||||
|
||||
assert total_words == 5
|
||||
|
||||
|
|
@ -459,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
|
||||
|
||||
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 page_most_tags == "2024-01-01"
|
||||
|
|
@ -475,7 +492,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db):
|
|||
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||
|
||||
assert date(2024, 1, 1) in revisions_by_date
|
||||
assert revisions_by_date[date(2024, 1, 1)] == 3
|
||||
|
|
@ -490,7 +507,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db):
|
|||
fresh_db.save_new_version("2024-01-15", "Test", "v1")
|
||||
|
||||
stats = fresh_db.gather_stats()
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
|
||||
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
|
||||
|
||||
# Should have parsed the date correctly
|
||||
assert date(2024, 1, 15) in revisions_by_date
|
||||
|
|
@ -503,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")
|
||||
|
||||
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
|
||||
assert total_words == 5
|
||||
|
|
@ -515,7 +532,7 @@ def test_db_gather_stats_no_tags(fresh_db):
|
|||
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
|
||||
|
||||
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 page_most_tags is None
|
||||
|
|
|
|||
289
tests/test_document_utils.py
Normal file
289
tests/test_document_utils.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_open_document_from_db_success(qtbot, app, fresh_db):
|
||||
"""Test successfully opening a document."""
|
||||
# Import here to avoid circular import issues
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Add a project and document
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content for document")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
# Mock QDesktopServices.openUrl
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
# Call the function
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id, doc_path.name, parent_widget=None
|
||||
)
|
||||
|
||||
# Verify success
|
||||
assert success is True
|
||||
|
||||
# Verify openUrl was called with a QUrl
|
||||
assert mock_open.called
|
||||
args = mock_open.call_args[0]
|
||||
assert isinstance(args[0], QUrl)
|
||||
|
||||
# Verify the URL points to a local file
|
||||
url_string = args[0].toString()
|
||||
assert url_string.startswith("file://")
|
||||
assert "bouquin_doc_" in url_string
|
||||
assert doc_path.suffix in url_string
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
|
||||
"""Test opening a document with a parent widget provided."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Create a parent widget
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
# Add a project and document
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".pdf"))
|
||||
doc_path.write_text("PDF content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id, doc_path.name, parent_widget=parent
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
|
||||
"""Test opening a non-existent document returns False."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Try to open a document that doesn't exist
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
|
||||
)
|
||||
|
||||
# Should return False
|
||||
assert success is False
|
||||
|
||||
|
||||
def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
|
||||
"""Test that error dialog is shown when parent widget is provided."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
# Mock QMessageBox.warning
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
|
||||
)
|
||||
|
||||
# Should return False and show warning
|
||||
assert success is False
|
||||
assert mock_warning.called
|
||||
|
||||
# Verify warning was shown with correct parent
|
||||
call_args = mock_warning.call_args[0]
|
||||
assert call_args[0] is parent
|
||||
|
||||
|
||||
def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
|
||||
"""Test that no error dialog is shown when parent widget is None."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
with patch.object(QMessageBox, "warning") as mock_warning:
|
||||
success = open_document_from_db(
|
||||
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
|
||||
)
|
||||
|
||||
# Should return False but NOT show warning
|
||||
assert success is False
|
||||
assert not mock_warning.called
|
||||
|
||||
|
||||
def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
|
||||
"""Test that the temporary file has the correct extension."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
# Test various file extensions
|
||||
extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
|
||||
|
||||
for ext in extensions:
|
||||
proj_id = fresh_db.add_project(f"Project for {ext}")
|
||||
doc_path = Path(tempfile.mktemp(suffix=ext))
|
||||
doc_path.write_text(f"content for {ext}")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(
|
||||
QDesktopServices, "openUrl", return_value=True
|
||||
) as mock_open:
|
||||
open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Get the URL that was opened
|
||||
url = mock_open.call_args[0][0]
|
||||
url_string = url.toString()
|
||||
|
||||
# Verify the extension is preserved
|
||||
assert ext in url_string, f"Extension {ext} not found in {url_string}"
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
|
||||
"""Test opening a document without a file extension."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp()) # No suffix
|
||||
doc_path.write_text("content without extension")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Should still succeed
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
|
||||
"""Test handling when QDesktopServices.openUrl returns False."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
# Mock openUrl to return False (failure)
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=False):
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
# Should return False
|
||||
assert success is False
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
|
||||
"""Test opening a document with binary content."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".bin"))
|
||||
|
||||
# Write some binary data
|
||||
binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
|
||||
doc_path.write_bytes(binary_data)
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_large_file(qtbot, app, fresh_db):
|
||||
"""Test opening a large document."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".bin"))
|
||||
|
||||
# Create a 1MB file
|
||||
large_data = b"x" * (1024 * 1024)
|
||||
doc_path.write_bytes(large_data)
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
assert success is True
|
||||
assert mock_open.called
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
|
||||
"""Test that temporary files have the correct prefix."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
|
||||
url = mock_open.call_args[0][0]
|
||||
url_path = url.toLocalFile()
|
||||
|
||||
# Verify the temp file has the bouquin_doc_ prefix
|
||||
assert "bouquin_doc_" in url_path
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
|
||||
"""Test opening the same document multiple times."""
|
||||
from bouquin.document_utils import open_document_from_db
|
||||
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
doc_path = Path(tempfile.mktemp(suffix=".txt"))
|
||||
doc_path.write_text("test content")
|
||||
|
||||
try:
|
||||
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
|
||||
|
||||
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
|
||||
# Open the same document 3 times
|
||||
for _ in range(3):
|
||||
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
|
||||
assert success is True
|
||||
|
||||
# Should have been called 3 times
|
||||
assert mock_open.call_count == 3
|
||||
|
||||
# Each call should create a different temp file
|
||||
call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
|
||||
# All URLs should be different (different temp files)
|
||||
assert len(set(call_urls)) == 3
|
||||
finally:
|
||||
doc_path.unlink(missing_ok=True)
|
||||
1060
tests/test_documents.py
Normal file
1060
tests/test_documents.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,9 @@
|
|||
import pytest
|
||||
|
||||
from bouquin.find_bar import FindBar
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QTextEdit, QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.find_bar import FindBar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
||||
|
|
|
|||
1346
tests/test_invoices.py
Normal file
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 PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QFileDialog, QLineEdit
|
||||
|
||||
|
|
@ -97,7 +96,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
|
|||
|
||||
|
||||
def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
|
||||
"""Test KeyPrompt with show_db_change but no initial_db_path - covers line 57"""
|
||||
"""Test KeyPrompt with show_db_change but no initial_db_path"""
|
||||
prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
|
||||
qtbot.addWidget(prompt)
|
||||
|
||||
|
|
@ -168,7 +167,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path):
|
|||
|
||||
|
||||
def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
||||
"""Test browsing when initial_db_path is set - covers line 57 with non-None path"""
|
||||
"""Test browsing when initial_db_path is set"""
|
||||
initial_db = tmp_path / "initial.db"
|
||||
initial_db.touch()
|
||||
|
||||
|
|
@ -180,7 +179,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
|
|||
|
||||
# Mock the file dialog to return a different file
|
||||
def mock_get_open_filename(*args, **kwargs):
|
||||
# Verify that start_dir was passed correctly (line 57)
|
||||
# Verify that start_dir was passed correctly
|
||||
return str(new_db), "SQLCipher DB (*.db)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from bouquin.lock_overlay import LockOverlay
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.lock_overlay import LockOverlay
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
def test_lock_overlay_reacts_to_theme(app, qtbot):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import importlib
|
||||
import runpy
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
import pytest
|
||||
import importlib.metadata
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import bouquin.main_window as mwmod
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
|
||||
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
|
||||
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bouquin.main_window as mwmod
|
||||
import bouquin.version_check as version_check
|
||||
import pytest
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
|
||||
|
||||
|
||||
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
|
|
@ -1861,44 +1858,76 @@ def test_main_window_update_tag_views_no_tags_widget(
|
|||
assert True
|
||||
|
||||
|
||||
def test_main_window_with_tags_disabled(qtbot, app, tmp_path):
|
||||
"""Test MainWindow with tags disabled in config - covers line 319"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when tags feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/tags", False) # Disable tags
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", False) # Disabled
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Tags widget should be hidden
|
||||
assert w.tags.isHidden()
|
||||
# Verify tags widget is hidden
|
||||
assert window.tags.isHidden()
|
||||
|
||||
|
||||
def test_main_window_with_time_log_disabled(qtbot, app, tmp_path):
|
||||
"""Test MainWindow with time_log disabled in config - covers line 321"""
|
||||
db_path = tmp_path / "notebook.db"
|
||||
def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when time_log feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(db_path))
|
||||
s.setValue("db/key", "test-key")
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", False) # Disable time log
|
||||
s.setValue("ui/time_log", False) # Disabled
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Time log widget should be hidden
|
||||
assert w.time_log.isHidden()
|
||||
# Verify time_log widget is hidden
|
||||
assert window.time_log.isHidden()
|
||||
assert not window.toolBar.actTimer.isVisible()
|
||||
|
||||
|
||||
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when documents feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/documents", False) # Disabled
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify documents widget is hidden
|
||||
assert window.todays_documents.isHidden()
|
||||
assert not window.toolBar.actDocuments.isVisible()
|
||||
|
||||
|
||||
def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
|
||||
|
|
@ -2161,53 +2190,6 @@ def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
|
|||
assert not window.toolBar.actAlarm.isVisible()
|
||||
|
||||
|
||||
def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when time_log feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", True)
|
||||
s.setValue("ui/time_log", False) # Disabled
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify time_log widget is hidden
|
||||
assert window.time_log.isHidden()
|
||||
assert not window.toolBar.actTimer.isVisible()
|
||||
|
||||
|
||||
def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
|
||||
"""Test main window when tags feature is disabled."""
|
||||
s = get_settings()
|
||||
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/tags", False) # Disabled
|
||||
s.setValue("ui/time_log", True)
|
||||
s.setValue("ui/reminders", True)
|
||||
s.setValue("ui/locale", "en")
|
||||
s.setValue("ui/font_size", 11)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
window = MainWindow(themes=themes)
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
|
||||
# Verify tags widget is hidden
|
||||
assert window.tags.isHidden()
|
||||
|
||||
|
||||
def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
"""Test closing the current tab via _close_current_tab."""
|
||||
s = get_settings()
|
||||
|
|
@ -2244,7 +2226,7 @@ def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
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)."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
|
|
@ -2270,7 +2252,7 @@ def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
window._open_date_in_tab(today_qdate)
|
||||
|
||||
# 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}"
|
||||
|
||||
# Set the editor's current_date attribute
|
||||
|
|
@ -2326,7 +2308,7 @@ def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
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."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
|
|
@ -2368,7 +2350,7 @@ def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
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."""
|
||||
from PySide6.QtCore import QTime
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QMimeData, QUrl
|
||||
from PySide6.QtGui import (
|
||||
QImage,
|
||||
QColor,
|
||||
QKeyEvent,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
QFont,
|
||||
QTextCharFormat,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.markdown_highlighter import MarkdownHighlighter
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QImage,
|
||||
QKeyEvent,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
|
||||
def _today():
|
||||
|
|
@ -1575,7 +1574,7 @@ def test_markdown_highlighter_special_characters(qtbot, app):
|
|||
highlighter = MarkdownHighlighter(doc, theme_manager)
|
||||
|
||||
text = """
|
||||
Special chars: < > & " '
|
||||
Special chars: < > & " '
|
||||
Escaped: \\* \\_ \\`
|
||||
Unicode: 你好 café résumé
|
||||
"""
|
||||
|
|
@ -1928,7 +1927,7 @@ def test_editor_delete_operations(qtbot, app):
|
|||
|
||||
|
||||
def test_markdown_highlighter_dark_theme(qtbot, app):
|
||||
"""Test markdown highlighter with dark theme - covers lines 74-75"""
|
||||
"""Test markdown highlighter with dark theme"""
|
||||
# Create theme manager with dark theme
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
||||
|
||||
|
|
@ -2293,7 +2292,7 @@ def test_highlighter_code_block_with_language(editor, qtbot):
|
|||
# Force rehighlight
|
||||
editor.highlighter.rehighlight()
|
||||
|
||||
# Verify syntax highlighting was applied (lines 186-193)
|
||||
# Verify syntax highlighting was applied
|
||||
# We can't easily verify the exact formatting, but we ensure no crash
|
||||
|
||||
|
||||
|
|
@ -2305,13 +2304,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot):
|
|||
# Force rehighlight
|
||||
editor.highlighter.rehighlight()
|
||||
|
||||
# The overlap detection (lines 252, 264) should prevent issues
|
||||
|
||||
|
||||
def test_highlighter_italic_edge_cases(editor, qtbot):
|
||||
"""Test italic formatting edge cases."""
|
||||
# Test edge case: avoiding stealing markers that are part of double
|
||||
# This tests lines 267-270
|
||||
editor.setPlainText("**not italic* text**")
|
||||
|
||||
# Force rehighlight
|
||||
|
|
|
|||
932
tests/test_markdown_editor_additional.py
Normal file
932
tests/test_markdown_editor_additional.py
Normal file
|
|
@ -0,0 +1,932 @@
|
|||
"""
|
||||
Additional tests for markdown_editor.py to improve test coverage.
|
||||
These tests should be added to test_markdown_editor.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QImage,
|
||||
QKeyEvent,
|
||||
QMouseEvent,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
)
|
||||
|
||||
|
||||
def text(editor) -> str:
|
||||
return editor.toPlainText()
|
||||
|
||||
|
||||
def lines_keep(editor):
|
||||
"""Split preserving a trailing empty line if the text ends with '\\n'."""
|
||||
return text(editor).split("\n")
|
||||
|
||||
|
||||
def press_backtick(qtbot, widget, n=1):
|
||||
"""Send physical backtick key events (avoid IME/dead-key issues)."""
|
||||
for _ in range(n):
|
||||
qtbot.keyClick(widget, Qt.Key_QuoteLeft)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
ed.show()
|
||||
qtbot.waitExposed(ed)
|
||||
ed.setFocus()
|
||||
return ed
|
||||
|
||||
|
||||
def test_update_code_block_backgrounds_with_no_document(app, qtbot):
|
||||
"""Test _update_code_block_row_backgrounds when document is None."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Create a new empty document to replace the current one
|
||||
new_doc = QTextDocument()
|
||||
editor.setDocument(new_doc)
|
||||
editor.setDocument(None)
|
||||
|
||||
# Should not crash even with no document
|
||||
editor._update_code_block_row_backgrounds()
|
||||
|
||||
|
||||
def test_find_code_block_bounds_invalid_block(editor):
|
||||
"""Test _find_code_block_bounds with invalid block."""
|
||||
editor.setPlainText("some text")
|
||||
|
||||
# Create an invalid block
|
||||
doc = editor.document()
|
||||
invalid_block = doc.findBlockByNumber(999) # doesn't exist
|
||||
|
||||
result = editor._find_code_block_bounds(invalid_block)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_find_code_block_bounds_on_closing_fence(editor):
|
||||
"""Test _find_code_block_bounds when on a closing fence."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
|
||||
doc = editor.document()
|
||||
closing_fence = doc.findBlockByNumber(2) # the closing ```
|
||||
|
||||
result = editor._find_code_block_bounds(closing_fence)
|
||||
assert result is not None
|
||||
open_block, close_block = result
|
||||
assert open_block.blockNumber() == 0
|
||||
assert close_block.blockNumber() == 2
|
||||
|
||||
|
||||
def test_find_code_block_bounds_on_opening_fence(editor):
|
||||
"""Test _find_code_block_bounds when on an opening fence."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
|
||||
doc = editor.document()
|
||||
opening_fence = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(opening_fence)
|
||||
assert result is not None
|
||||
open_block, close_block = result
|
||||
assert open_block.blockNumber() == 0
|
||||
assert close_block.blockNumber() == 2
|
||||
|
||||
|
||||
def test_find_code_block_bounds_no_closing_fence(editor):
|
||||
"""Test _find_code_block_bounds when closing fence is missing."""
|
||||
editor.setPlainText("```\ncode without closing")
|
||||
|
||||
doc = editor.document()
|
||||
opening_fence = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(opening_fence)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_find_code_block_bounds_no_opening_fence(editor):
|
||||
"""Test _find_code_block_bounds from inside code block with no opening."""
|
||||
# Simulate a malformed block (shouldn't happen in practice)
|
||||
editor.setPlainText("code\n```")
|
||||
|
||||
doc = editor.document()
|
||||
code_line = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._find_code_block_bounds(code_line)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_edit_code_block_checks_document(app, qtbot):
|
||||
"""Test _edit_code_block when editor has no document."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set up editor with code block
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
# Now remove the document
|
||||
editor.setDocument(None)
|
||||
|
||||
# The method will try to work but should handle gracefully
|
||||
# It actually returns True because it processes the block from the old doc
|
||||
# This tests that it doesn't crash
|
||||
editor._edit_code_block(block)
|
||||
# Just verify it doesn't crash - return value is implementation dependent
|
||||
|
||||
|
||||
def test_edit_code_block_dialog_cancelled(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block when dialog is cancelled."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class CancelledDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = language
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Rejected
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", CancelledDialog)
|
||||
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
# Should return True (event handled) even though cancelled
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_edit_code_block_with_delete(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block when user deletes the block."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class DeleteDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = language
|
||||
self._deleted = True
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
def was_deleted(self):
|
||||
return self._deleted
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", DeleteDialog)
|
||||
|
||||
editor.setPlainText("```python\noriginal code\n```\nafter")
|
||||
editor.toPlainText()
|
||||
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Code block should be deleted
|
||||
new_text = editor.toPlainText()
|
||||
assert "original code" not in new_text
|
||||
|
||||
|
||||
def test_edit_code_block_language_change(editor, qtbot, monkeypatch):
|
||||
"""Test _edit_code_block with language change."""
|
||||
import bouquin.markdown_editor as markdown_editor
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
class LanguageChangeDialog:
|
||||
def __init__(self, code, language, parent=None, allow_delete=False):
|
||||
self._code = code
|
||||
self._language = "javascript" # Change from python
|
||||
|
||||
def exec(self):
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def language(self):
|
||||
return self._language
|
||||
|
||||
monkeypatch.setattr(markdown_editor, "CodeBlockEditorDialog", LanguageChangeDialog)
|
||||
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._edit_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Verify metadata was updated
|
||||
assert hasattr(editor, "_code_metadata")
|
||||
lang = editor._code_metadata.get_language(0)
|
||||
assert lang == "javascript"
|
||||
|
||||
|
||||
def test_delete_code_block_no_bounds(editor):
|
||||
"""Test _delete_code_block when bounds can't be found."""
|
||||
editor.setPlainText("not a code block")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(0)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_delete_code_block_checks_document(app, qtbot):
|
||||
"""Test _delete_code_block when editor has no document."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Set up with code block
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
# Remove the document
|
||||
editor.setDocument(None)
|
||||
|
||||
# The method will attempt to work but should handle gracefully
|
||||
# Just verify it doesn't crash
|
||||
editor._delete_code_block(block)
|
||||
|
||||
|
||||
def test_delete_code_block_at_end_of_document(editor):
|
||||
"""Test _delete_code_block when code block is at end of document."""
|
||||
editor.setPlainText("```\ncode\n```") # No trailing newline
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Should be empty or minimal
|
||||
assert "code" not in editor.toPlainText()
|
||||
|
||||
|
||||
def test_delete_code_block_with_text_after(editor):
|
||||
"""Test _delete_code_block when there's text after the block."""
|
||||
editor.setPlainText("```\ncode\n```\ntext after")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
result = editor._delete_code_block(block)
|
||||
assert result is True
|
||||
|
||||
# Code should be gone, text after should remain
|
||||
new_text = editor.toPlainText()
|
||||
assert "code" not in new_text
|
||||
assert "text after" in new_text
|
||||
|
||||
|
||||
def test_apply_line_spacing_no_document(app):
|
||||
"""Test _apply_line_spacing when document is None."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
|
||||
editor.setDocument(None)
|
||||
|
||||
# Should not crash
|
||||
editor._apply_line_spacing(125.0)
|
||||
|
||||
|
||||
def test_apply_code_block_spacing(editor):
|
||||
"""Test _apply_code_block_spacing applies correct spacing."""
|
||||
editor.setPlainText("```\nline1\nline2\n```")
|
||||
|
||||
# Apply spacing
|
||||
editor._apply_code_block_spacing()
|
||||
|
||||
# Verify blocks have spacing applied
|
||||
doc = editor.document()
|
||||
for i in range(doc.blockCount()):
|
||||
block = doc.findBlockByNumber(i)
|
||||
assert block.isValid()
|
||||
|
||||
|
||||
def test_to_markdown_with_code_metadata(editor):
|
||||
"""Test to_markdown includes code block metadata."""
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
|
||||
# Set some metadata
|
||||
editor._code_metadata.set_language(0, "python")
|
||||
|
||||
md = editor.to_markdown()
|
||||
|
||||
# Should include metadata comment
|
||||
assert "code-langs" in md or "code" in md
|
||||
|
||||
|
||||
def test_from_markdown_creates_code_metadata(app):
|
||||
"""Test from_markdown creates _code_metadata if missing."""
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
editor = MarkdownEditor(themes)
|
||||
|
||||
# Remove the attribute
|
||||
if hasattr(editor, "_code_metadata"):
|
||||
delattr(editor, "_code_metadata")
|
||||
|
||||
# Should recreate it
|
||||
editor.from_markdown("# test")
|
||||
|
||||
assert hasattr(editor, "_code_metadata")
|
||||
|
||||
|
||||
def test_embed_images_preserves_original_size(editor, tmp_path):
|
||||
"""Test that embedded images preserve their original dimensions."""
|
||||
# Create a test image
|
||||
img = tmp_path / "test.png"
|
||||
qimg = QImage(100, 50, QImage.Format_RGBA8888)
|
||||
qimg.fill(QColor(255, 0, 0))
|
||||
qimg.save(str(img))
|
||||
|
||||
# Create markdown with image
|
||||
import base64
|
||||
|
||||
with open(img, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
md = f""
|
||||
editor.from_markdown(md)
|
||||
|
||||
# Image should be embedded with original size
|
||||
doc = editor.document()
|
||||
assert doc is not None
|
||||
|
||||
|
||||
def test_trim_list_prefix_no_selection(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection with no selection."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.clearSelection()
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not crash
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_multiline_selection(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection across multiple lines."""
|
||||
editor.setPlainText("- item1\n- item2")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not trim multi-line selections
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_not_full_line(editor):
|
||||
"""Test _maybe_trim_list_prefix_from_line_selection with partial selection."""
|
||||
editor.setPlainText("- item text here")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 5)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Partial line selection should not be trimmed
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_already_after_prefix(editor):
|
||||
"""Test _maybe_trim_list_prefix when selection already after prefix."""
|
||||
editor.setPlainText("- item text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3) # After "- "
|
||||
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Should not need adjustment
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
|
||||
def test_trim_list_prefix_during_adjustment(editor):
|
||||
"""Test _maybe_trim_list_prefix re-entry guard."""
|
||||
editor.setPlainText("- item")
|
||||
editor._adjusting_selection = True
|
||||
|
||||
# Should return early due to guard
|
||||
editor._maybe_trim_list_prefix_from_line_selection()
|
||||
|
||||
editor._adjusting_selection = False
|
||||
|
||||
|
||||
def test_detect_list_type_checkbox_checked(editor):
|
||||
"""Test _detect_list_type with checked checkbox."""
|
||||
list_type, prefix = editor._detect_list_type(
|
||||
f"{editor._CHECK_CHECKED_DISPLAY} done"
|
||||
)
|
||||
assert list_type == "checkbox"
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in prefix
|
||||
|
||||
|
||||
def test_detect_list_type_numbered(editor):
|
||||
"""Test _detect_list_type with numbered list."""
|
||||
list_type, prefix = editor._detect_list_type("1. item")
|
||||
assert list_type == "number"
|
||||
# The prefix will be "2. " because it increments for the next item
|
||||
assert "." in prefix
|
||||
|
||||
|
||||
def test_detect_list_type_markdown_bullet(editor):
|
||||
"""Test _detect_list_type with markdown bullet."""
|
||||
list_type, prefix = editor._detect_list_type("- item")
|
||||
assert list_type == "bullet"
|
||||
|
||||
|
||||
def test_detect_list_type_not_a_list(editor):
|
||||
"""Test _detect_list_type with regular text."""
|
||||
list_type, prefix = editor._detect_list_type("regular text")
|
||||
assert list_type is None
|
||||
assert prefix == ""
|
||||
|
||||
|
||||
def test_list_prefix_length_numbered(editor):
|
||||
"""Test _list_prefix_length_for_block with numbered list."""
|
||||
editor.setPlainText("123. item")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(0)
|
||||
|
||||
length = editor._list_prefix_length_for_block(block)
|
||||
assert length > 0
|
||||
|
||||
|
||||
def test_key_press_ctrl_home(editor, qtbot):
|
||||
"""Test Ctrl+Home key combination."""
|
||||
editor.setPlainText("line1\nline2\nline3")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.ControlModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move to start of document
|
||||
assert editor.textCursor().position() == 0
|
||||
|
||||
|
||||
def test_key_press_ctrl_left(editor, qtbot):
|
||||
"""Test Ctrl+Left key combination."""
|
||||
editor.setPlainText("word1 word2 word3")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.ControlModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move left by word
|
||||
|
||||
|
||||
def test_key_press_home_in_list(editor, qtbot):
|
||||
"""Test Home key in list item."""
|
||||
editor.setPlainText("- item text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Home, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should jump to after "- "
|
||||
pos = editor.textCursor().position()
|
||||
assert pos > 0
|
||||
|
||||
|
||||
def test_key_press_left_in_list_prefix(editor, qtbot):
|
||||
"""Test Left key when in list prefix region."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right) # Inside "- "
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Left, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should snap to after prefix
|
||||
|
||||
|
||||
def test_key_press_up_in_code_block(editor, qtbot):
|
||||
"""Test Up key inside code block."""
|
||||
editor.setPlainText("```\ncode line 1\ncode line 2\n```")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Down)
|
||||
cursor.movePosition(QTextCursor.Down) # On "code line 2"
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Up, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should move up normally in code block
|
||||
|
||||
|
||||
def test_key_press_down_in_list_item(editor, qtbot):
|
||||
"""Test Down key in list item."""
|
||||
editor.setPlainText("- item1\n- item2")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right) # In prefix of first item
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Down, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should snap to after prefix on next line
|
||||
|
||||
|
||||
def test_key_press_enter_after_markers(editor, qtbot):
|
||||
"""Test Enter key after style markers."""
|
||||
editor.setPlainText("text **")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should handle markers
|
||||
|
||||
|
||||
def test_key_press_enter_on_closing_fence(editor, qtbot):
|
||||
"""Test Enter key on closing fence line."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.StartOfLine)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should create new line after fence
|
||||
|
||||
|
||||
def test_key_press_backspace_empty_checkbox(editor, qtbot):
|
||||
"""Test Backspace in empty checkbox item."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should remove checkbox
|
||||
|
||||
|
||||
def test_key_press_backspace_numbered_list(editor, qtbot):
|
||||
"""Test Backspace at start of numbered list item."""
|
||||
editor.setPlainText("1. ")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Backspace, Qt.NoModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
|
||||
def test_key_press_tab_in_bullet_list(editor, qtbot):
|
||||
"""Test Tab key in bullet list."""
|
||||
editor.setPlainText("- item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should indent
|
||||
|
||||
|
||||
def test_key_press_shift_tab_in_bullet_list(editor, qtbot):
|
||||
"""Test Shift+Tab in indented bullet list."""
|
||||
editor.setPlainText(" - item")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.ShiftModifier, "")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
# Should unindent
|
||||
|
||||
|
||||
def test_key_press_tab_in_checkbox(editor, qtbot):
|
||||
"""Test Tab in checkbox item."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier, "\t")
|
||||
editor.keyPressEvent(event)
|
||||
|
||||
|
||||
def test_apply_weight_to_selection(editor, qtbot):
|
||||
"""Test apply_weight makes text bold."""
|
||||
editor.setPlainText("text to bold")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_weight()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "**" in md
|
||||
|
||||
|
||||
def test_apply_italic_to_selection(editor, qtbot):
|
||||
"""Test apply_italic makes text italic."""
|
||||
editor.setPlainText("text to italicize")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_italic()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "*" in md or "_" in md
|
||||
|
||||
|
||||
def test_apply_strikethrough_to_selection(editor, qtbot):
|
||||
"""Test apply_strikethrough."""
|
||||
editor.setPlainText("text to strike")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_strikethrough()
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "~~" in md
|
||||
|
||||
|
||||
def test_apply_code_on_selection(editor, qtbot):
|
||||
"""Test apply_code with selected text."""
|
||||
editor.setPlainText("some code")
|
||||
cursor = editor.textCursor()
|
||||
cursor.select(QTextCursor.Document)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# apply_code opens dialog - with test stub it accepts
|
||||
editor.apply_code()
|
||||
|
||||
# The stub dialog will create a code block
|
||||
editor.toPlainText()
|
||||
# May contain code block elements depending on dialog behavior
|
||||
|
||||
|
||||
def test_toggle_numbers_on_plain_text(editor, qtbot):
|
||||
"""Test toggle_numbers converts text to numbered list."""
|
||||
editor.setPlainText("item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_numbers()
|
||||
|
||||
text = editor.toPlainText()
|
||||
assert "1." in text
|
||||
|
||||
|
||||
def test_toggle_bullets_on_plain_text(editor, qtbot):
|
||||
"""Test toggle_bullets converts text to bullet list."""
|
||||
editor.setPlainText("item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_bullets()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Will have unicode bullet
|
||||
assert editor._BULLET_DISPLAY in text
|
||||
|
||||
|
||||
def test_toggle_bullets_removes_bullets(editor, qtbot):
|
||||
"""Test toggle_bullets removes existing bullets."""
|
||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_bullets()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should have removed bullet
|
||||
assert text.strip() == "item 1"
|
||||
|
||||
|
||||
def test_toggle_checkboxes_on_bullets(editor, qtbot):
|
||||
"""Test toggle_checkboxes converts bullets to checkboxes."""
|
||||
editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.toggle_checkboxes()
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should have checkbox characters
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_apply_heading_various_levels(editor, qtbot):
|
||||
"""Test apply_heading with different levels."""
|
||||
test_cases = [
|
||||
(24, "#"), # H1
|
||||
(18, "##"), # H2
|
||||
(14, "###"), # H3
|
||||
(12, ""), # Normal (no heading)
|
||||
]
|
||||
|
||||
for size, expected_marker in test_cases:
|
||||
editor.setPlainText("heading text")
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
editor.apply_heading(size)
|
||||
|
||||
text = editor.toPlainText()
|
||||
if expected_marker:
|
||||
assert text.startswith(expected_marker)
|
||||
|
||||
|
||||
def test_insert_image_from_path_invalid_extension(editor, tmp_path):
|
||||
"""Test insert_image_from_path with invalid extension."""
|
||||
invalid_file = tmp_path / "file.txt"
|
||||
invalid_file.write_text("not an image")
|
||||
|
||||
# Should not crash
|
||||
editor.insert_image_from_path(invalid_file)
|
||||
|
||||
|
||||
def test_insert_image_from_path_nonexistent(editor, tmp_path):
|
||||
"""Test insert_image_from_path with nonexistent file."""
|
||||
nonexistent = tmp_path / "doesnt_exist.png"
|
||||
|
||||
# Should not crash
|
||||
editor.insert_image_from_path(nonexistent)
|
||||
|
||||
|
||||
def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot):
|
||||
"""Test clicking checkbox toggles it from unchecked to checked."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
pos = QPoint(rect.left() + 2, rect.center().y())
|
||||
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
|
||||
)
|
||||
|
||||
editor.mousePressEvent(event)
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should toggle to checked
|
||||
assert editor._CHECK_CHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot):
|
||||
"""Test clicking checked checkbox toggles it to unchecked."""
|
||||
editor.setPlainText(f"{editor._CHECK_CHECKED_DISPLAY} completed task")
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
pos = QPoint(rect.left() + 2, rect.center().y())
|
||||
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonPress, pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier
|
||||
)
|
||||
|
||||
editor.mousePressEvent(event)
|
||||
|
||||
text = editor.toPlainText()
|
||||
# Should toggle to unchecked
|
||||
assert editor._CHECK_UNCHECKED_DISPLAY in text
|
||||
|
||||
|
||||
def test_mouse_double_click_suppression(editor, qtbot):
|
||||
"""Test double-click suppression for checkboxes."""
|
||||
editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task")
|
||||
|
||||
# Simulate the suppression flag being set
|
||||
editor._suppress_next_checkbox_double_click = True
|
||||
|
||||
pos = QPoint(10, 10)
|
||||
event = QMouseEvent(
|
||||
QMouseEvent.MouseButtonDblClick,
|
||||
pos,
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.NoModifier,
|
||||
)
|
||||
|
||||
editor.mouseDoubleClickEvent(event)
|
||||
|
||||
# Flag should be cleared
|
||||
assert not editor._suppress_next_checkbox_double_click
|
||||
|
||||
|
||||
def test_context_menu_in_code_block(editor, qtbot):
|
||||
"""Test context menu when in code block."""
|
||||
editor.setPlainText("```python\ncode\n```")
|
||||
|
||||
from PySide6.QtGui import QContextMenuEvent
|
||||
|
||||
# Position in the code block
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Down)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
rect = editor.cursorRect()
|
||||
QContextMenuEvent(QContextMenuEvent.Mouse, rect.center())
|
||||
|
||||
# Should not crash
|
||||
# Note: actual menu exec is blocked in tests, but we verify it doesn't crash
|
||||
|
||||
|
||||
def test_set_code_block_language(editor, qtbot):
|
||||
"""Test _set_code_block_language sets metadata."""
|
||||
editor.setPlainText("```\ncode\n```")
|
||||
doc = editor.document()
|
||||
block = doc.findBlockByNumber(1)
|
||||
|
||||
editor._set_code_block_language(block, "python")
|
||||
|
||||
# Metadata should be set
|
||||
lang = editor._code_metadata.get_language(0)
|
||||
assert lang == "python"
|
||||
|
||||
|
||||
def test_get_current_line_task_text_strips_prefixes(editor, qtbot):
|
||||
"""Test get_current_line_task_text removes list/checkbox prefixes."""
|
||||
test_cases = [
|
||||
(f"{editor._CHECK_UNCHECKED_DISPLAY} task text", "task text"),
|
||||
(f"{editor._BULLET_DISPLAY} bullet text", "bullet text"),
|
||||
("- markdown bullet", "markdown bullet"),
|
||||
("1. numbered item", "numbered item"),
|
||||
]
|
||||
|
||||
for input_text, expected in test_cases:
|
||||
editor.setPlainText(input_text)
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
result = editor.get_current_line_task_text()
|
||||
assert result == expected
|
||||
|
||||
|
||||
# Test for selection changed event
|
||||
def test_selection_changed_in_list(editor, qtbot):
|
||||
"""Test selectionChanged event in list items."""
|
||||
editor.setPlainText("- item one\n- item two")
|
||||
|
||||
# Select text in first item
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.Start)
|
||||
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 3)
|
||||
cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
# Trigger selection changed
|
||||
editor.selectionChanged.emit()
|
||||
|
||||
# Should handle gracefully
|
||||
|
|
@ -1,5 +1,55 @@
|
|||
from unittest.mock import Mock, patch
|
||||
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
|
||||
|
||||
from bouquin.pomodoro_timer import PomodoroManager, PomodoroTimer
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QLabel, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class DummyTimeLogWidget(QWidget):
|
||||
"""Minimal stand-in for the real TimeLogWidget used by PomodoroManager."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.summary_label = QLabel(self)
|
||||
# toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped
|
||||
self.toggle_btn = Mock()
|
||||
self.toggle_btn.isChecked.return_value = True
|
||||
|
||||
def show_pomodoro_widget(self, widget):
|
||||
# Manager calls this when embedding the timer
|
||||
if widget is not None:
|
||||
self.layout.addWidget(widget)
|
||||
|
||||
def clear_pomodoro_widget(self):
|
||||
# Manager calls this when removing the embedded timer
|
||||
while self.layout.count():
|
||||
item = self.layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.setParent(None)
|
||||
|
||||
def _reload_summary(self):
|
||||
# Called after TimeLogDialog closes; no-op is fine for tests
|
||||
pass
|
||||
|
||||
|
||||
class DummyMainWindow(QWidget):
|
||||
"""Minimal stand-in for MainWindow that PomodoroManager expects."""
|
||||
|
||||
def __init__(self, app, parent=None):
|
||||
super().__init__(parent)
|
||||
# Sidebar time log widget
|
||||
self.time_log = DummyTimeLogWidget(self)
|
||||
|
||||
# Toolbar with an actTimer QAction so QSignalBlocker works
|
||||
self.toolBar = QToolBar(self)
|
||||
self.toolBar.actTimer = QAction(self)
|
||||
self.toolBar.addAction(self.toolBar.actTimer)
|
||||
|
||||
# Themes attribute used when constructing TimeLogDialog
|
||||
self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
||||
def test_pomodoro_timer_init(qtbot, app, fresh_db):
|
||||
|
|
@ -147,15 +197,6 @@ def test_pomodoro_timer_modal_state(qtbot, app):
|
|||
assert timer.isModal() is False
|
||||
|
||||
|
||||
def test_pomodoro_timer_window_title(qtbot, app):
|
||||
"""Test timer window title."""
|
||||
timer = PomodoroTimer("Test task")
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Window title should contain some reference to timer/pomodoro
|
||||
assert len(timer.windowTitle()) > 0
|
||||
|
||||
|
||||
def test_pomodoro_manager_init(app, fresh_db):
|
||||
"""Test PomodoroManager initialization."""
|
||||
parent = Mock()
|
||||
|
|
@ -168,10 +209,10 @@ def test_pomodoro_manager_init(app, fresh_db):
|
|||
|
||||
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
|
||||
"""Test starting a timer through the manager."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
line_text = "Important task"
|
||||
|
|
@ -181,15 +222,16 @@ def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
|
|||
|
||||
assert manager._active_timer is not None
|
||||
assert manager._active_timer._task_text == line_text
|
||||
qtbot.addWidget(manager._active_timer)
|
||||
# Timer should be embedded in the sidebar time log widget
|
||||
assert manager._active_timer.parent() is parent.time_log
|
||||
|
||||
|
||||
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
|
||||
"""Test that starting a new timer closes the previous one."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
"""Test that starting a new timer closes/replaces the previous one."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Start first timer
|
||||
|
|
@ -205,16 +247,20 @@ def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
|
|||
|
||||
assert first_timer is not second_timer
|
||||
assert second_timer._task_text == "Task 2"
|
||||
assert second_timer.parent() is parent.time_log
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped with very short time logs minimum hours."""
|
||||
parent = Mock()
|
||||
"""Timer stopped with very short time logs should enforce minimum hours."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog to avoid actually showing it
|
||||
# Mock TimeLogDialog to avoid showing it
|
||||
mock_dialog = Mock()
|
||||
mock_dialog.hours_spin = Mock()
|
||||
mock_dialog.note = Mock()
|
||||
|
|
@ -230,8 +276,11 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
|
|||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
|
||||
"""Test that elapsed time is properly rounded to decimal hours."""
|
||||
parent = Mock()
|
||||
"""Elapsed time should be rounded up to the nearest 0.25 hours."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
|
|
@ -240,21 +289,25 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
|
|||
mock_dialog.exec = Mock()
|
||||
|
||||
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
|
||||
# Test with 1800 seconds (30 minutes)
|
||||
# 1800 seconds (30 min) should round up to 0.5
|
||||
manager._on_timer_stopped(1800, "Task", "2024-01-15")
|
||||
|
||||
mock_dialog.hours_spin.setValue.assert_called_once()
|
||||
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
|
||||
# Should round up and be a multiple of 0.25
|
||||
|
||||
assert hours_set > 0
|
||||
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
|
||||
# Should be a multiple of 0.25
|
||||
assert hours_set * 4 == int(hours_set * 4)
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_prefills_note(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped pre-fills the note in time log dialog."""
|
||||
parent = Mock()
|
||||
"""Timer stopped should pre-fill the note in the time log dialog."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
mock_dialog = Mock()
|
||||
|
|
@ -273,11 +326,11 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note(
|
|||
def test_pomodoro_manager_timer_stopped_signal_connection(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
"""Test that timer stopped signal is properly connected."""
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
"""Timer's stop button should result in TimeLogDialog being executed."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
||||
manager = PomodoroManager(fresh_db, parent)
|
||||
|
||||
# Mock TimeLogDialog
|
||||
|
|
@ -291,11 +344,12 @@ def test_pomodoro_manager_timer_stopped_signal_connection(
|
|||
timer = manager._active_timer
|
||||
qtbot.addWidget(timer)
|
||||
|
||||
# Simulate timer stopped
|
||||
# Simulate timer having run for a bit
|
||||
timer._elapsed_seconds = 1000
|
||||
|
||||
# Clicking "Stop and log" should emit timerStopped and open the dialog
|
||||
timer._stop_and_log()
|
||||
|
||||
# TimeLogDialog should have been created
|
||||
assert mock_dialog.exec.called
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,42 @@
|
|||
from unittest.mock import patch
|
||||
from datetime import date, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bouquin.reminders import (
|
||||
Reminder,
|
||||
ReminderType,
|
||||
ReminderDialog,
|
||||
UpcomingRemindersWidget,
|
||||
ManageRemindersDialog,
|
||||
Reminder,
|
||||
ReminderDialog,
|
||||
ReminderType,
|
||||
UpcomingRemindersWidget,
|
||||
)
|
||||
from PySide6.QtCore import QDate, QTime
|
||||
from PySide6.QtCore import QDate, QDateTime, QTime
|
||||
from PySide6.QtWidgets import QDialog, QMessageBox, QWidget
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
@pytest.fixture
|
||||
def freeze_reminders_time(monkeypatch):
|
||||
# Freeze 'now' used inside bouquin.reminders to 12:00 today
|
||||
import bouquin.reminders as rem
|
||||
|
||||
today = QDate.currentDate()
|
||||
fixed_time = QTime(12, 0)
|
||||
fixed_dt = QDateTime(today, fixed_time)
|
||||
monkeypatch.setattr(
|
||||
rem.QDateTime, "currentDateTime", staticmethod(lambda: QDateTime(fixed_dt))
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def _add_daily_reminder(db, text="Standup", time_str="23:59"):
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text=text,
|
||||
time_str=time_str,
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = db.save_reminder(r)
|
||||
return r
|
||||
|
||||
|
||||
def test_reminder_type_enum(app):
|
||||
|
|
@ -274,20 +301,24 @@ def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
|
|||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=None,
|
||||
text="New reminder",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
new_reminder = Reminder(
|
||||
id=None,
|
||||
text="New reminder",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
widget._add_reminder()
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = new_reminder
|
||||
|
||||
# Reminder should be saved
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
assert len(reminders) > 0
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
widget._add_reminder()
|
||||
|
||||
# Reminder should be saved
|
||||
reminders = fresh_db.get_all_reminders()
|
||||
assert len(reminders) > 0
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
|
||||
|
|
@ -310,17 +341,20 @@ def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
|
|||
if widget.reminder_list.count() > 0:
|
||||
item = widget.reminder_list.item(0)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
mock_get.return_value = updated
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
widget._edit_reminder(item)
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = updated
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
widget._edit_reminder(item)
|
||||
|
||||
|
||||
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
|
||||
|
|
@ -380,17 +414,6 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
|
|||
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):
|
||||
"""Test ManageRemindersDialog initialization."""
|
||||
dialog = ManageRemindersDialog(fresh_db)
|
||||
|
|
@ -435,19 +458,23 @@ def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
|
|||
|
||||
initial_count = dialog.table.rowCount()
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=None,
|
||||
text="New",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
new_reminder = Reminder(
|
||||
id=None,
|
||||
text="New",
|
||||
time_str="10:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
dialog._add_reminder()
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = new_reminder
|
||||
|
||||
# Table should have one more row
|
||||
assert dialog.table.rowCount() == initial_count + 1
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
dialog._add_reminder()
|
||||
|
||||
# Table should have one more row
|
||||
assert dialog.table.rowCount() == initial_count + 1
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
|
||||
|
|
@ -464,16 +491,20 @@ def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
|
|||
dialog = ManageRemindersDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
|
||||
with patch.object(ReminderDialog, "get_reminder") as mock_get:
|
||||
mock_get.return_value = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
updated = Reminder(
|
||||
id=1,
|
||||
text="Updated",
|
||||
time_str="11:00",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
)
|
||||
|
||||
dialog._edit_reminder(reminder)
|
||||
# Mock the entire ReminderDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
mock_dialog.get_reminder.return_value = updated
|
||||
|
||||
with patch("bouquin.reminders.ReminderDialog", return_value=mock_dialog):
|
||||
dialog._edit_reminder(reminder)
|
||||
|
||||
|
||||
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
|
||||
|
|
@ -544,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
|
|||
qtbot.addWidget(dialog)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
|
|
@ -599,7 +630,11 @@ def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
|
|||
widget = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
with patch.object(ManageRemindersDialog, "exec"):
|
||||
# Mock the entire ManageRemindersDialog class to avoid Qt parent issues
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
|
||||
with patch("bouquin.reminders.ManageRemindersDialog", return_value=mock_dialog):
|
||||
widget._manage_reminders()
|
||||
|
||||
|
||||
|
|
@ -799,3 +834,104 @@ def test_edit_reminder_dialog(qtbot, fresh_db):
|
|||
# Verify fields are populated
|
||||
assert dlg.text_edit.text() == "Original text"
|
||||
assert dlg.time_edit.time().toString("HH:mm") == "14:30"
|
||||
|
||||
|
||||
def test_upcoming_reminders_context_menu_shows(
|
||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||
):
|
||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
# Add a future reminder for today
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text="Ping",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = fresh_db.save_reminder(r)
|
||||
|
||||
w = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(w)
|
||||
w.refresh()
|
||||
|
||||
# Select first upcoming item so context menu code path runs
|
||||
assert w.reminder_list.count() > 0
|
||||
w.reminder_list.setCurrentItem(w.reminder_list.item(0))
|
||||
|
||||
called = {"exec": False, "actions": []}
|
||||
|
||||
class DummyAction:
|
||||
def __init__(self, text, parent=None):
|
||||
self._text = text
|
||||
|
||||
class _Sig:
|
||||
def connect(self, fn):
|
||||
pass
|
||||
|
||||
self.triggered = _Sig()
|
||||
|
||||
class DummyMenu:
|
||||
def __init__(self, parent=None):
|
||||
pass
|
||||
|
||||
def addAction(self, action):
|
||||
called["actions"].append(getattr(action, "_text", str(action)))
|
||||
|
||||
def exec(self, *_, **__):
|
||||
called["exec"] = True
|
||||
|
||||
# Patch the modules that the inline imports will read from
|
||||
monkeypatch.setattr(QtWidgets, "QMenu", DummyMenu, raising=True)
|
||||
monkeypatch.setattr(QtGui, "QAction", DummyAction, raising=True)
|
||||
|
||||
# Invoke directly (normally via right-click)
|
||||
w._show_reminder_context_menu(QPoint(5, 5))
|
||||
|
||||
assert called["exec"] is True
|
||||
assert len(called["actions"]) >= 2 # at least Edit/Deactivate/Delete
|
||||
|
||||
|
||||
def test_upcoming_reminders_delete_selected_dedupes(
|
||||
qtbot, app, fresh_db, freeze_reminders_time, monkeypatch
|
||||
):
|
||||
from bouquin.reminders import Reminder, ReminderType, UpcomingRemindersWidget
|
||||
from PySide6.QtCore import QItemSelectionModel
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
r = Reminder(
|
||||
id=None,
|
||||
text="Duplicate target",
|
||||
time_str="23:59",
|
||||
reminder_type=ReminderType.DAILY,
|
||||
active=True,
|
||||
)
|
||||
r.id = fresh_db.save_reminder(r)
|
||||
|
||||
w = UpcomingRemindersWidget(fresh_db)
|
||||
qtbot.addWidget(w)
|
||||
w.refresh()
|
||||
|
||||
assert w.reminder_list.count() >= 2 # daily -> multiple upcoming occurrences
|
||||
|
||||
# First selects & clears; second adds to selection
|
||||
w.reminder_list.setCurrentRow(0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
|
||||
w.reminder_list.setCurrentRow(1, QItemSelectionModel.SelectionFlag.Select)
|
||||
|
||||
deleted_ids = []
|
||||
|
||||
def fake_delete(rem_id):
|
||||
deleted_ids.append(rem_id)
|
||||
|
||||
# Auto-confirm deletion
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.Yes)
|
||||
)
|
||||
monkeypatch.setattr(fresh_db, "delete_reminder", fake_delete)
|
||||
|
||||
w._delete_selected_reminders()
|
||||
|
||||
# Should de-duplicate to a single DB delete call
|
||||
assert deleted_ids == [r.id]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ def test_open_selected_with_data(qtbot, fresh_db):
|
|||
it = QListWidgetItem("dummy")
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
|
||||
it.setData(
|
||||
Qt.ItemDataRole.UserRole,
|
||||
{"kind": "page", "date": "1999-12-31"},
|
||||
)
|
||||
s.results.addItem(it)
|
||||
s._open_selected(it)
|
||||
assert seen == ["1999-12-31"]
|
||||
|
|
@ -95,6 +98,6 @@ def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
|
|||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
long = "X" * 40 + "alpha" + "Y" * 40
|
||||
rows = [("2000-01-01", long)]
|
||||
rows = [("page", "2000-01-01", "2000-01-01", long, None)]
|
||||
s._populate_results("alpha", rows)
|
||||
assert s.results.count() >= 1
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
from bouquin.settings import (
|
||||
get_settings,
|
||||
load_db_config,
|
||||
save_db_config,
|
||||
)
|
||||
from bouquin.db import DBConfig
|
||||
from bouquin.settings import get_settings, load_db_config, save_db_config
|
||||
|
||||
|
||||
def _clear_db_settings():
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
import bouquin.settings_dialog as sd
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import datetime as _dt
|
||||
from datetime import datetime, timedelta, date
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from bouquin import strings
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QDate
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
|
||||
class FakeStatsDB:
|
||||
|
|
@ -16,6 +14,7 @@ class FakeStatsDB:
|
|||
def __init__(self):
|
||||
d1 = _dt.date(2024, 1, 1)
|
||||
d2 = _dt.date(2024, 1, 2)
|
||||
|
||||
self.stats = (
|
||||
2, # pages_with_content
|
||||
5, # total_revisions
|
||||
|
|
@ -27,7 +26,20 @@ class FakeStatsDB:
|
|||
"2024-01-02", # page_most_tags
|
||||
2, # page_most_tags_count
|
||||
{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
|
||||
|
||||
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
|
||||
words_by_date = db.stats[4]
|
||||
revisions_by_date = db.stats[-1]
|
||||
revisions_by_date = db.stats[9]
|
||||
|
||||
assert hasattr(dlg, "_heatmap")
|
||||
assert dlg._heatmap._data == words_by_date
|
||||
|
|
@ -82,13 +94,25 @@ class EmptyStatsDB:
|
|||
0, # pages_with_content
|
||||
0, # total_revisions
|
||||
None, # page_most_revisions
|
||||
0,
|
||||
0, # page_most_revisions_count
|
||||
{}, # words_by_date
|
||||
0, # total_words
|
||||
0, # unique_tags
|
||||
None, # page_most_tags
|
||||
0,
|
||||
0, # page_most_tags_count
|
||||
{}, # 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import types
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import bouquin.strings as strings
|
||||
import pytest
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QEvent, QDate
|
||||
from PySide6.QtGui import QMouseEvent, QColor
|
||||
from bouquin.db import DBManager
|
||||
from bouquin.flow_layout import FlowLayout
|
||||
from bouquin.strings import load_strings
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
from bouquin.tags_widget import PageTagsWidget, TagChip
|
||||
from PySide6.QtCore import QDate, QEvent, QPoint, Qt
|
||||
from PySide6.QtGui import QColor, QMouseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
QColorDialog,
|
||||
QDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from bouquin.db import DBManager
|
||||
from bouquin.strings import load_strings
|
||||
from bouquin.tags_widget import PageTagsWidget, TagChip
|
||||
from bouquin.tag_browser import TagBrowserDialog
|
||||
from bouquin.flow_layout import FlowLayout
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
import bouquin.strings as strings
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DB Layer Tag Tests
|
||||
# ============================================================================
|
||||
|
|
@ -1649,7 +1646,7 @@ def test_default_tag_colour_none(fresh_db):
|
|||
|
||||
def test_flow_layout_take_at_invalid_index(app):
|
||||
"""Test FlowLayout.takeAt with out-of-bounds index"""
|
||||
from PySide6.QtWidgets import QWidget, QLabel
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
widget = QWidget()
|
||||
layout = FlowLayout(widget)
|
||||
|
|
@ -1673,7 +1670,7 @@ def test_flow_layout_take_at_invalid_index(app):
|
|||
|
||||
def test_flow_layout_take_at_boundary(app):
|
||||
"""Test FlowLayout.takeAt at exact boundary"""
|
||||
from PySide6.QtWidgets import QWidget, QLabel
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
widget = QWidget()
|
||||
layout = FlowLayout(widget)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
||||
def test_theme_manager_apply_light_and_dark(app):
|
||||
cfg = ThemeConfig(theme=Theme.LIGHT)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from PySide6.QtCore import Qt, QDate
|
||||
from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QInputDialog,
|
||||
QFileDialog,
|
||||
)
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
import bouquin.strings as strings
|
||||
import pytest
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.time_log import (
|
||||
TimeLogWidget,
|
||||
TimeLogDialog,
|
||||
TimeCodeManagerDialog,
|
||||
TimeLogDialog,
|
||||
TimeLogWidget,
|
||||
TimeReportDialog,
|
||||
)
|
||||
import bouquin.strings as strings
|
||||
from PySide6.QtCore import QDate, Qt
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog, QInputDialog, QMessageBox
|
||||
from sqlcipher3.dbapi2 import IntegrityError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -1187,8 +1184,8 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
|
|||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.project_combo.count() == 0
|
||||
assert dialog.granularity.count() == 3 # day, week, month
|
||||
assert dialog.project_combo.count() == 1
|
||||
assert dialog.granularity.count() == 5
|
||||
|
||||
|
||||
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
||||
|
|
@ -1199,18 +1196,18 @@ def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
|||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.project_combo.count() == 2
|
||||
assert dialog.project_combo.count() == 3
|
||||
|
||||
|
||||
def test_time_report_dialog_default_date_range(qtbot, fresh_db):
|
||||
"""Dialog defaults to last 7 days."""
|
||||
"""Dialog defaults to start of month."""
|
||||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
today = QDate.currentDate()
|
||||
week_ago = today.addDays(-7)
|
||||
start_of_month = QDate(today.year(), today.month(), 1)
|
||||
|
||||
assert dialog.from_date.date() == week_ago
|
||||
assert dialog.from_date.date() == start_of_month
|
||||
assert dialog.to_date.date() == today
|
||||
|
||||
|
||||
|
|
@ -1227,12 +1224,14 @@ def test_time_report_dialog_run_report(qtbot, fresh_db):
|
|||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog.from_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()
|
||||
|
||||
assert dialog.table.rowCount() == 1
|
||||
assert "Activity" in dialog.table.item(0, 1).text()
|
||||
assert "Activity" in dialog.table.item(0, 2).text()
|
||||
assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text()
|
||||
|
||||
|
||||
|
|
@ -1414,13 +1413,18 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db):
|
|||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog.from_date.setDate(QDate.fromString(date1, "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()
|
||||
|
||||
# Should aggregate to single week
|
||||
assert dialog.table.rowCount() == 1
|
||||
# In grouped modes the Note column is hidden → hours are in column 3
|
||||
hours_text = dialog.table.item(0, 3).text()
|
||||
|
||||
assert "2.5" in hours_text or "2.50" in hours_text
|
||||
|
||||
|
||||
|
|
@ -1442,13 +1446,17 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db):
|
|||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog.from_date.setDate(QDate.fromString(date1, "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()
|
||||
|
||||
# Should aggregate to single month
|
||||
assert dialog.table.rowCount() == 1
|
||||
hours_text = dialog.table.item(0, 3).text()
|
||||
|
||||
assert "2.5" in hours_text or "2.50" in hours_text
|
||||
|
||||
|
||||
|
|
@ -1497,40 +1505,6 @@ def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db):
|
|||
assert "1.50h" in summary
|
||||
|
||||
|
||||
def test_time_report_dialog_csv_export_handles_os_error(
|
||||
qtbot, fresh_db, tmp_path, monkeypatch
|
||||
):
|
||||
"""CSV export handles OSError gracefully."""
|
||||
strings.load_strings("en")
|
||||
proj_id = fresh_db.add_project("Project")
|
||||
act_id = fresh_db.add_activity("Activity")
|
||||
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
|
||||
|
||||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog._run_report()
|
||||
|
||||
# Use a path that will cause an error (e.g., directory instead of file)
|
||||
bad_path = str(tmp_path)
|
||||
|
||||
def mock_get_save_filename(*args, **kwargs):
|
||||
return bad_path, "CSV Files (*.csv)"
|
||||
|
||||
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
|
||||
|
||||
warning_shown = {"shown": False}
|
||||
|
||||
def mock_warning(*args):
|
||||
warning_shown["shown"] = True
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||
|
||||
dialog._export_csv()
|
||||
assert warning_shown["shown"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Additional TimeLogWidget Edge Cases
|
||||
# ============================================================================
|
||||
|
|
@ -1968,10 +1942,13 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db):
|
|||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog.project_combo.setCurrentIndex(1)
|
||||
dialog.from_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()
|
||||
|
||||
|
|
@ -2007,7 +1984,10 @@ def test_time_report_dialog_pdf_export_with_multiple_periods(
|
|||
dialog.project_combo.setCurrentIndex(0)
|
||||
dialog.from_date.setDate(QDate.fromString(date1, "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()
|
||||
|
||||
|
|
@ -2185,10 +2165,10 @@ def test_full_workflow_add_project_activity_log_report(
|
|||
|
||||
# Verify report
|
||||
assert report_dialog.table.rowCount() == 1
|
||||
assert "Test Activity" in report_dialog.table.item(0, 1).text()
|
||||
assert "Test Activity" in report_dialog.table.item(0, 2).text()
|
||||
assert (
|
||||
"2.5" in report_dialog.table.item(0, 3).text()
|
||||
or "2.50" in report_dialog.table.item(0, 3).text()
|
||||
"2.5" in report_dialog.table.item(0, 4).text()
|
||||
or "2.50" in report_dialog.table.item(0, 4).text()
|
||||
)
|
||||
|
||||
# 5. Export CSV
|
||||
|
|
@ -2596,3 +2576,437 @@ def test_time_log_with_entry(qtbot, fresh_db):
|
|||
|
||||
# Widget should have been created successfully
|
||||
assert widget is not None
|
||||
|
||||
|
||||
def test_time_log_widget_open_dialog_log_only_when_no_date(qtbot, app, fresh_db):
|
||||
"""Test _open_dialog_log_only when _current_date is None."""
|
||||
widget = TimeLogWidget(fresh_db, themes=None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Set current date to None
|
||||
widget._current_date = None
|
||||
|
||||
# Click should return early without crashing
|
||||
widget._open_dialog_log_only()
|
||||
|
||||
# No dialog should be shown
|
||||
|
||||
|
||||
def test_time_log_widget_open_dialog_log_only_opens_dialog(qtbot, app, fresh_db):
|
||||
"""Test _open_dialog_log_only opens TimeLogDialog."""
|
||||
widget = TimeLogWidget(fresh_db, themes=None)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Set a valid date
|
||||
widget._current_date = "2024-01-15"
|
||||
|
||||
# Mock TimeLogDialog
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
|
||||
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
|
||||
widget._open_dialog_log_only()
|
||||
|
||||
# Dialog should have been created with correct parameters
|
||||
assert mock_dialog.exec.called
|
||||
|
||||
|
||||
def test_time_log_widget_open_dialog_log_only_refreshes_when_collapsed(
|
||||
qtbot, app, fresh_db
|
||||
):
|
||||
"""Test that opening dialog updates summary when widget is collapsed."""
|
||||
widget = TimeLogWidget(fresh_db, themes=None)
|
||||
qtbot.addWidget(widget)
|
||||
widget._current_date = "2024-01-15"
|
||||
|
||||
# Collapse the widget
|
||||
widget.toggle_btn.setChecked(False)
|
||||
|
||||
# Mock TimeLogDialog
|
||||
mock_dialog = MagicMock()
|
||||
mock_dialog.exec.return_value = QDialog.Accepted
|
||||
|
||||
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
|
||||
widget._open_dialog_log_only()
|
||||
|
||||
# Should show collapsed hint
|
||||
assert (
|
||||
"collapsed" in widget.summary_label.text().lower()
|
||||
or widget.summary_label.text() != ""
|
||||
)
|
||||
|
||||
|
||||
def test_time_log_dialog_log_entry_only_mode(qtbot, app, fresh_db):
|
||||
"""Test TimeLogDialog in log_entry_only mode."""
|
||||
dialog = TimeLogDialog(
|
||||
fresh_db, "2024-01-15", log_entry_only=True, themes=None, close_after_add=True
|
||||
)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# In log_entry_only mode, these should be hidden
|
||||
assert not dialog.delete_btn.isVisible()
|
||||
assert not dialog.report_btn.isVisible()
|
||||
assert not dialog.table.isVisible()
|
||||
|
||||
|
||||
def test_time_log_dialog_log_entry_only_false(qtbot, app, fresh_db):
|
||||
"""Test TimeLogDialog in normal mode (log_entry_only=False)."""
|
||||
dialog = TimeLogDialog(
|
||||
fresh_db, "2024-01-15", log_entry_only=False, themes=None, close_after_add=False
|
||||
)
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show()
|
||||
qtbot.waitExposed(dialog)
|
||||
|
||||
# In normal mode, these should be visible
|
||||
assert dialog.delete_btn.isVisible()
|
||||
assert dialog.report_btn.isVisible()
|
||||
assert dialog.table.isVisible()
|
||||
|
||||
|
||||
def test_time_log_dialog_change_date_cancelled(qtbot, app, fresh_db):
|
||||
"""Test _on_change_date_clicked when user cancels."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Mock exec to return rejected
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
original_date = dialog._date_iso
|
||||
dialog._on_change_date_clicked()
|
||||
|
||||
# Date should not change when cancelled
|
||||
assert dialog._date_iso == original_date
|
||||
|
||||
|
||||
def test_time_log_dialog_change_date_accepted(qtbot, app, fresh_db):
|
||||
"""Test _on_change_date_clicked when user accepts (covers lines 410-450)."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Mock exec to return accepted - the dialog will use whatever date is in the calendar
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
# Just verify it doesn't crash - actual date may or may not change
|
||||
# depending on what the real QCalendarWidget selects
|
||||
dialog._on_change_date_clicked()
|
||||
|
||||
# Dialog should still be functional
|
||||
assert dialog._date_iso is not None
|
||||
|
||||
|
||||
def test_time_log_dialog_change_date_with_invalid_current_date(qtbot, app, fresh_db):
|
||||
"""Test _on_change_date_clicked when current date is invalid (covers lines 410-412)."""
|
||||
dialog = TimeLogDialog(fresh_db, "invalid-date", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should fall back to current date without crashing
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
dialog._on_change_date_clicked()
|
||||
|
||||
|
||||
def test_time_log_dialog_change_date_with_themes(qtbot, app, fresh_db):
|
||||
"""Test _on_change_date_clicked with theme manager (covers line 423-424)."""
|
||||
themes_mock = MagicMock()
|
||||
themes_mock.register_calendar = MagicMock()
|
||||
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=themes_mock)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Mock exec to return rejected
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
dialog._on_change_date_clicked()
|
||||
|
||||
# Theme should have been applied to calendar
|
||||
assert themes_mock.register_calendar.called
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_incomplete_row(qtbot, app, fresh_db):
|
||||
"""Test _on_table_item_changed with incomplete row."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add incomplete row
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
# Only add project item, missing others
|
||||
proj_item = QTableWidgetItem("Project")
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
|
||||
# Call _on_table_item_changed
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
# Should return early without crashing (covers lines 556-558)
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_creates_new_project(qtbot, app, fresh_db):
|
||||
"""Test _on_table_item_changed creating a new project on the fly."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add a complete row with new project name
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
proj_item = QTableWidgetItem("Brand New Project")
|
||||
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
|
||||
act_item = QTableWidgetItem("Activity")
|
||||
note_item = QTableWidgetItem("Note")
|
||||
hours_item = QTableWidgetItem("2.5")
|
||||
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
dialog.table.setItem(0, 1, act_item)
|
||||
dialog.table.setItem(0, 2, note_item)
|
||||
dialog.table.setItem(0, 3, hours_item)
|
||||
|
||||
# Call _on_table_item_changed
|
||||
with patch.object(dialog, "_on_add_or_update"):
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
# Should have created project and called add/update
|
||||
projects = fresh_db.list_projects()
|
||||
project_names = [name for _, name in projects]
|
||||
assert "Brand New Project" in project_names
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_without_note(qtbot, app, fresh_db):
|
||||
"""Test _on_table_item_changed when note_item is None."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add row without note
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
proj_item = QTableWidgetItem("Project")
|
||||
proj_item.setData(Qt.ItemDataRole.UserRole, None)
|
||||
act_item = QTableWidgetItem("Activity")
|
||||
hours_item = QTableWidgetItem("1.0")
|
||||
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
dialog.table.setItem(0, 1, act_item)
|
||||
# Note: Don't set note_item (leave as None)
|
||||
dialog.table.setItem(0, 3, hours_item)
|
||||
|
||||
# Call _on_table_item_changed
|
||||
with patch.object(dialog, "_on_add_or_update"):
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
# Should handle None note gracefully (covers line 567)
|
||||
assert dialog.note.text() == ""
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_sets_button_state_for_new_entry(
|
||||
qtbot, app, fresh_db
|
||||
):
|
||||
"""Test that _on_table_item_changed sets correct button state for new entry."""
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add row without entry ID (new entry)
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
proj_item = QTableWidgetItem("Project")
|
||||
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
|
||||
act_item = QTableWidgetItem("Activity")
|
||||
hours_item = QTableWidgetItem("1.0")
|
||||
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
dialog.table.setItem(0, 1, act_item)
|
||||
dialog.table.setItem(0, 3, hours_item)
|
||||
|
||||
with patch.object(dialog, "_on_add_or_update"):
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
# Delete button should be disabled for new entry (covers lines 601-603)
|
||||
assert not dialog.delete_btn.isEnabled()
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_sets_button_state_for_existing_entry(
|
||||
qtbot, app, fresh_db
|
||||
):
|
||||
"""Test that _on_table_item_changed sets correct button state for existing entry."""
|
||||
# Add a time log entry first
|
||||
proj_id = fresh_db.add_project("Test Project")
|
||||
act_id = fresh_db.add_activity("Activity")
|
||||
entry_id = fresh_db.add_time_log(
|
||||
"2024-01-15", proj_id, act_id, 120, "Note"
|
||||
) # 120 minutes = 2 hours
|
||||
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add row with entry ID
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
proj_item = QTableWidgetItem("Test Project")
|
||||
proj_item.setData(Qt.ItemDataRole.UserRole, entry_id)
|
||||
act_item = QTableWidgetItem("Activity")
|
||||
hours_item = QTableWidgetItem("2.0")
|
||||
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
dialog.table.setItem(0, 1, act_item)
|
||||
dialog.table.setItem(0, 3, hours_item)
|
||||
|
||||
with patch.object(dialog, "_on_add_or_update"):
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
# Delete button should be enabled for existing entry (covers lines 604-606)
|
||||
assert dialog.delete_btn.isEnabled()
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
|
||||
def test_time_log_dialog_table_item_changed_finds_existing_project_by_name(
|
||||
qtbot, app, fresh_db
|
||||
):
|
||||
"""Test _on_table_item_changed finding existing project by name."""
|
||||
proj_id = fresh_db.add_project("Existing Project")
|
||||
|
||||
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Block signals to prevent Qt cleanup
|
||||
dialog.table.blockSignals(True)
|
||||
|
||||
# Add row with existing project name
|
||||
dialog.table.setRowCount(1)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
proj_item = QTableWidgetItem("Existing Project")
|
||||
proj_item.setData(Qt.ItemDataRole.UserRole, None)
|
||||
act_item = QTableWidgetItem("Activity")
|
||||
hours_item = QTableWidgetItem("1.0")
|
||||
|
||||
dialog.table.setItem(0, 0, proj_item)
|
||||
dialog.table.setItem(0, 1, act_item)
|
||||
dialog.table.setItem(0, 3, hours_item)
|
||||
|
||||
with patch.object(dialog, "_on_add_or_update"):
|
||||
dialog._on_table_item_changed(proj_item)
|
||||
|
||||
# Should find and select existing project (covers lines 571-580)
|
||||
assert dialog.project_combo.currentData() == proj_id
|
||||
|
||||
dialog.table.blockSignals(False)
|
||||
|
||||
|
||||
def test_time_report_dialog_initialization(qtbot, app, fresh_db):
|
||||
"""Test TimeReportDialog initialization."""
|
||||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should initialize without crashing
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_time_code_manager_dialog_initialization(qtbot, app, fresh_db):
|
||||
"""Test TimeCodeManagerDialog initialization."""
|
||||
dialog = TimeCodeManagerDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Should initialize without crashing
|
||||
assert dialog is not None
|
||||
|
||||
|
||||
def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db):
|
||||
"""Test TimeCodeManagerDialog with initial tab focus."""
|
||||
# Test with projects tab
|
||||
dialog = TimeCodeManagerDialog(fresh_db, focus_tab="projects")
|
||||
qtbot.addWidget(dialog)
|
||||
assert dialog.tabs.currentIndex() == 0
|
||||
|
||||
# Test with activities tab
|
||||
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
|
||||
qtbot.addWidget(dialog2)
|
||||
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
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.toolbar import ToolBar
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import subprocess
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from bouquin.version_check import VersionChecker
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
|
||||
def test_version_checker_init(app):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue