Compare commits

..

17 commits
0.8.0 ... main

Author SHA1 Message Date
5bb61273da
Fix version change
All checks were successful
CI / test (push) Successful in 14m5s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 25s
2026-01-30 17:03:38 +11:00
7f2c88f52b
Fix carrying over data to next day from over-capturing data belonging to next header section
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
Other dependency updates
2026-01-30 16:49:45 +11:00
9f399c589d
Update urllib3 dependency to resolve CVE-2026-21441
All checks were successful
CI / test (push) Successful in 13m36s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-09 12:07:46 +11:00
dd1ae74b19
remove 'fc' from release root
All checks were successful
CI / test (push) Successful in 13m51s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-03 12:50:36 +11:00
5f89c4286e
Fix releasing for Fedora
All checks were successful
CI / test (push) Successful in 13m32s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 23s
2026-01-03 12:11:49 +11:00
7bb2746a0f
Prep for supporting other fedora versions later
All checks were successful
CI / test (push) Successful in 13m41s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 24s
2026-01-01 17:09:37 +11:00
b192264dbf
server migration
All checks were successful
CI / test (push) Successful in 8m5s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-31 17:12:21 +11:00
f8aab05cb7
Bump to 0.8.2
All checks were successful
CI / test (push) Successful in 8m4s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-31 16:09:29 +11:00
04f67a786f
Add ability to delete an invoice via Manage Invoices dialog 2025-12-31 16:09:16 +11:00
827565838f
Bump to 0.8.1
All checks were successful
CI / test (push) Successful in 8m23s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 18s
2025-12-26 18:02:04 +11:00
dce124e083
prep for 0.8.1 2025-12-26 18:01:56 +11:00
7e47cef602
Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
All checks were successful
CI / test (push) Successful in 8m21s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 18s
2025-12-26 17:06:45 +11:00
9c7cb7ba2b
Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
Some checks failed
CI / test (push) Failing after 8m4s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-26 09:03:20 +11:00
2eba0df85a
copy the rpm after signing it, you idiot
All checks were successful
CI / test (push) Successful in 8m54s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 18s
2025-12-24 18:55:55 +11:00
48e18e0408
Sign rpms before createrepo_c
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
2025-12-24 18:27:24 +11:00
26c136900e
Add chown and rpmsign step to rpm build
All checks were successful
CI / test (push) Successful in 8m39s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 18s
2025-12-24 17:58:20 +11:00
9b457278f9
Add rpm
All checks were successful
CI / test (push) Successful in 8m7s
Lint / test (push) Successful in 35s
Trivy / test (push) Successful in 18s
2025-12-24 15:26:41 +11:00
16 changed files with 1430 additions and 267 deletions

View file

@ -1,3 +1,18 @@
# 0.8.3
* Update urllib3 dependency to resolve CVE-2026-21441
* Fix carrying over data to next day from over-capturing data belonging to next header section
* Other dependency updates
# 0.8.2
* Add ability to delete an invoice via 'Manage Invoices' dialog
# 0.8.1
* Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
* Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
# 0.8.0 # 0.8.0
* Add .desktop file for Debian * Add .desktop file for Debian

110
Dockerfile.rpmbuild Normal file
View file

@ -0,0 +1,110 @@
# syntax=docker/dockerfile:1
FROM fedora:42
# rpmbuild in a container does not auto-install BuildRequires. Since we're
# building directly in Docker (not mock), we pre-install the common deps that
# Fedora's pyproject macros will require for Bouquin.
#
# NOTE: bouquin also needs python3dist(sqlcipher4) at build time (because
# %pyproject_buildrequires includes runtime deps). That one is NOT in Fedora;
# we install it from /deps.
RUN set -eux; \
dnf -y update; \
dnf -y install \
rpm-build rpmdevtools \
redhat-rpm-config \
gcc \
make \
findutils \
tar \
gzip \
rsync \
python3 \
python3-devel \
python3-pip \
python3-setuptools \
python3-wheel \
pyproject-rpm-macros \
python3-rpm-macros \
python3-poetry-core \
desktop-file-utils \
python3-requests \
python3-markdown \
python3-pyside6 \
xcb-util-cursor ; \
dnf -y clean all
RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
DEPS_DIR="${DEPS_DIR:-/deps}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}"
# Install bouquin-sqlcipher4 from local rpm
# Filter out .src.rpm and debug* subpackages if present.
if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)' | grep "${VERSION_ID}")
if [ "${#rpms[@]}" -gt 0 ]; then
echo "Installing dependency RPMs from ${DEPS_DIR}:"
printf ' - %s\n' "${rpms[@]}"
dnf -y install "${rpms[@]}"
dnf -y clean all
else
echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
fi
else
echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(sqlcipher4)," >&2
echo " mount your bouquin-sqlcipher4 RPM directory as -v <dir>:/deps" >&2
fi
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
rm -rf "${WORK}"
mkdir -p "${WORK}"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
# Determine version from pyproject.toml unless provided
if [ -n "${VERSION:-}" ]; then
ver="${VERSION}"
else
ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')"
fi
TOPDIR="${WORKROOT}/rpmbuild"
mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tarball="${TOPDIR}/SOURCES/bouquin-${ver}.tar.gz"
tar -czf "${tarball}" --transform "s#^#bouquin/#" .
cp -v "rpm/bouquin.spec" "${TOPDIR}/SPECS/bouquin.spec"
rpmbuild -ba "${TOPDIR}/SPECS/bouquin.spec" \
--define "_topdir ${TOPDIR}" \
--define "upstream_version ${ver}"
shopt -s nullglob
cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true
cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-rpm
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-rpm"]

View file

@ -101,6 +101,25 @@ sudo apt update
sudo apt install bouquin sudo apt install bouquin
``` ```
### Fedora 42
```bash
sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mig5.net/static/mig5.asc
EOF
sudo dnf upgrade --refresh
sudo dnf install bouquin
```
### From PyPi/pip ### From PyPi/pip
* `pip install bouquin` * `pip install bouquin`

View file

@ -2392,6 +2392,18 @@ class DBManager:
(document_id, invoice_id), (document_id, invoice_id),
) )
def delete_invoice(self, invoice_id: int) -> None:
"""Delete an invoice.
Related invoice line items and invoice time log links are removed via
ON DELETE CASCADE.
"""
with self.conn:
self.conn.execute(
"DELETE FROM invoices WHERE id = ?",
(invoice_id,),
)
def time_logs_for_range( def time_logs_for_range(
self, self,
project_id: int, project_id: int,

View file

@ -1065,6 +1065,10 @@ class InvoicesDialog(QDialog):
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch(1) btn_row.addStretch(1)
delete_btn = QPushButton(strings._("delete"))
delete_btn.clicked.connect(self._on_delete_clicked)
btn_row.addWidget(delete_btn)
close_btn = QPushButton(strings._("close")) close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn) btn_row.addWidget(close_btn)
@ -1073,6 +1077,68 @@ class InvoicesDialog(QDialog):
self._reload_invoices() self._reload_invoices()
# ----------------------------------------------------------------- deletion
def _on_delete_clicked(self) -> None:
"""Delete the currently selected invoice."""
row = self.table.currentRow()
if row < 0:
sel = self.table.selectionModel().selectedRows()
if sel:
row = sel[0].row()
if row < 0:
QMessageBox.information(
self,
strings._("delete"),
strings._("invoice_required"),
)
return
base_item = self.table.item(row, self.COL_NUMBER)
if base_item is None:
return
inv_id = base_item.data(Qt.ItemDataRole.UserRole)
if not inv_id:
return
invoice_number = (base_item.text() or "").strip() or "?"
proj_item = self.table.item(row, self.COL_PROJECT)
project_name = (proj_item.text() if proj_item is not None else "").strip()
label = strings._("delete")
prompt = (
f"{label} '{invoice_number}'"
+ (f" ({project_name})" if project_name else "")
+ "?"
)
resp = QMessageBox.question(
self,
label,
prompt,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
return
# Remove any automatically created due-date reminder.
if self.cfg.reminders:
self._remove_invoice_due_reminder(row, int(inv_id))
try:
self._db.delete_invoice(int(inv_id))
except Exception as e:
QMessageBox.warning(
self,
strings._("error"),
f"Failed to delete invoice: {e}",
)
return
self._reload_invoices()
# ------------------------------------------------------------------ helpers # ------------------------------------------------------------------ helpers
def _reload_projects(self) -> None: def _reload_projects(self) -> None:

View file

@ -436,5 +436,6 @@
"invoice_invalid_date_format": "Invalid date format", "invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid", "invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice", "invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required" "invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
} }

View file

@ -432,5 +432,6 @@
"invoice_invalid_date_format": "Format de date invalide", "invoice_invalid_date_format": "Format de date invalide",
"invoice_invalid_tax_rate": "Le taux de TVA est invalide", "invoice_invalid_tax_rate": "Le taux de TVA est invalide",
"invoice_no_items": "La facture ne contient aucun article", "invoice_no_items": "La facture ne contient aucun article",
"invoice_number_required": "Un numéro de facture est requis" "invoice_number_required": "Un numéro de facture est requis",
"invoice_required": "Veuillez sélectionner une facture spécifique avant d'essayer de supprimer la facture."
} }

View file

@ -28,7 +28,6 @@ from PySide6.QtGui import (
QGuiApplication, QGuiApplication,
QKeySequence, QKeySequence,
QTextCursor, QTextCursor,
QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@ -871,6 +870,13 @@ class MainWindow(QMainWindow):
into the rollover target date (today, or next Monday if today into the rollover target date (today, or next Monday if today
is a weekend). is a weekend).
In addition to moving the unchecked checkbox *line* itself, this also
moves any subsequent lines that belong to that unchecked item, stopping
at the next *checked* checkbox line **or** the next markdown heading.
This allows code fences, collapsed blocks, and notes under a todo to
travel with it without accidentally pulling in the next section.
Returns True if any items were moved, False otherwise. Returns True if any items were moved, False otherwise.
""" """
if not getattr(self.cfg, "move_todos", False): if not getattr(self.cfg, "move_todos", False):
@ -885,7 +891,9 @@ class MainWindow(QMainWindow):
# Regexes for markdown headings and checkboxes # Regexes for markdown headings and checkboxes
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$") heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+") unchecked_re = re.compile(r"^(\s*)-\s*\[[\s☐]\]\s+(.*)$")
checked_re = re.compile(r"^(\s*)-\s*\[[xX☑]\]\s+(.*)$")
fence_re = re.compile(r"^\s*(`{3,}|~{3,})")
def _normalize_heading(text: str) -> str: def _normalize_heading(text: str) -> str:
""" """
@ -896,13 +904,47 @@ class MainWindow(QMainWindow):
text = re.sub(r"\s+#+\s*$", "", text) text = re.sub(r"\s+#+\s*$", "", text)
return text.strip() return text.strip()
def _insert_todos_under_heading( def _update_fence_state(
line: str, in_fence: bool, fence_marker: str | None
) -> tuple[bool, str | None]:
"""
Track fenced code blocks (``` / ~~~). We ignore checkbox markers inside
fences so we don't accidentally split/move based on "- [x]" that appears
in code.
"""
m = fence_re.match(line)
if not m:
return in_fence, fence_marker
marker = m.group(1)
if not in_fence:
return True, marker
# Close only when we see a fence of the same char and >= length
if (
fence_marker
and marker[0] == fence_marker[0]
and len(marker) >= len(fence_marker)
):
return False, None
return in_fence, fence_marker
def _is_list_item(line: str) -> bool:
s = line.lstrip()
return bool(
re.match(r"^([-*+]\s+|\d+\.\s+)", s)
or unchecked_re.match(line)
or checked_re.match(line)
)
def _insert_blocks_under_heading(
target_lines: list[str], target_lines: list[str],
heading_level: int, heading_level: int,
heading_text: str, heading_text: str,
todos: list[str], blocks: list[list[str]],
) -> list[str]: ) -> list[str]:
"""Ensure a heading exists and append todos to the end of its section.""" """Ensure a heading exists and append blocks to the end of its section."""
normalized = _normalize_heading(heading_text) normalized = _normalize_heading(heading_text)
# 1) Find existing heading with same text (any level) # 1) Find existing heading with same text (any level)
@ -942,15 +984,137 @@ class MainWindow(QMainWindow):
): ):
insert_at -= 1 insert_at -= 1
for todo in todos: # Insert blocks (preserve internal blank lines)
target_lines.insert(insert_at, todo) for block in blocks:
insert_at += 1 if not block:
continue
# Avoid gluing a paragraph to the new block unless both look like list items
if (
insert_at > start_idx + 1
and target_lines[insert_at - 1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[insert_at - 1])
and _is_list_item(block[0])
)
):
target_lines.insert(insert_at, "")
insert_at += 1
for line in block:
target_lines.insert(insert_at, line)
insert_at += 1
return target_lines return target_lines
# Collect moved todos as (heading_info, item_text) def _prune_empty_headings(src_lines: list[str]) -> list[str]:
"""Remove markdown headings whose section became empty.
The rollover logic removes unchecked todo *blocks* but intentionally keeps
headings on the source day so we can re-create the same section on the
target day. If a heading ends up with no remaining content (including
empty subheadings), we should remove it from the source day too.
Headings inside fenced code blocks are ignored.
"""
# Identify headings (outside fences) and their levels
heading_levels: dict[int, int] = {}
heading_indices: list[int] = []
in_f = False
f_mark: str | None = None
for idx, ln in enumerate(src_lines):
if not in_f:
m = heading_re.match(ln)
if m:
heading_indices.append(idx)
heading_levels[idx] = len(m.group(1))
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
if not heading_indices:
return src_lines
# Compute each heading's section boundary: next heading with level <= current
boundary: dict[int, int] = {}
stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while stack and lvl <= heading_levels[stack[-1]]:
boundary[stack.pop()] = idx
stack.append(idx)
for idx in stack:
boundary[idx] = len(src_lines)
# Build parent/children relationships based on heading levels
children: dict[int, list[int]] = {}
parent_stack: list[int] = []
for idx in heading_indices:
lvl = heading_levels[idx]
while parent_stack and lvl <= heading_levels[parent_stack[-1]]:
parent_stack.pop()
if parent_stack:
children.setdefault(parent_stack[-1], []).append(idx)
parent_stack.append(idx)
# Determine whether each heading has any non-heading, non-blank content in its span
has_body: dict[int, bool] = {}
for h_idx in heading_indices:
end = boundary[h_idx]
body = False
in_f = False
f_mark = None
for j in range(h_idx + 1, end):
ln = src_lines[j]
if not in_f:
if ln.strip() and not heading_re.match(ln):
body = True
break
in_f, f_mark = _update_fence_state(ln, in_f, f_mark)
has_body[h_idx] = body
# Bottom-up: keep headings that have body content or any kept child headings
keep: dict[int, bool] = {}
for h_idx in reversed(heading_indices):
keep_child = any(keep.get(ch, False) for ch in children.get(h_idx, []))
keep[h_idx] = has_body[h_idx] or keep_child
remove_set = {idx for idx, k in keep.items() if not k}
if not remove_set:
return src_lines
# Remove empty headings and any immediate blank lines following them
out: list[str] = []
i = 0
while i < len(src_lines):
if i in remove_set:
i += 1
while i < len(src_lines) and src_lines[i].strip() == "":
i += 1
continue
out.append(src_lines[i])
i += 1
# Normalize excessive blank lines created by removals
cleaned: list[str] = []
prev_blank = False
for ln in out:
blank = ln.strip() == ""
if blank and prev_blank:
continue
cleaned.append(ln)
prev_blank = blank
while cleaned and cleaned[0].strip() == "":
cleaned.pop(0)
while cleaned and cleaned[-1].strip() == "":
cleaned.pop()
return cleaned
# Collect moved blocks as (heading_info, block_lines)
# heading_info is either None or (level, heading_text) # heading_info is either None or (level, heading_text)
moved_items: list[tuple[tuple[int, str] | None, str]] = [] moved_blocks: list[tuple[tuple[int, str] | None, list[str]]] = []
any_moved = False any_moved = False
# Look back N days (yesterday = 1, up to `days_back`) # Look back N days (yesterday = 1, up to `days_back`)
@ -966,28 +1130,87 @@ class MainWindow(QMainWindow):
moved_from_this_day = False moved_from_this_day = False
current_heading: tuple[int, str] | None = None current_heading: tuple[int, str] | None = None
for line in lines: in_fence = False
# Track the last seen heading (# / ## / ###) fence_marker: str | None = None
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 "- [☐] " i = 0
if unchecked_re.match(line): while i < len(lines):
item_text = unchecked_re.sub("", line) line = lines[i]
moved_items.append((current_heading, item_text))
moved_from_this_day = True # If we're not in a fenced code block, we can interpret headings/checkboxes
any_moved = True if not in_fence:
else: # Track the last seen heading (# / ## / ###)
remaining_lines.append(line) m_head = heading_re.match(line)
if m_head:
level = len(m_head.group(1))
heading_text = _normalize_heading(m_head.group(2))
if level <= 3:
current_heading = (level, heading_text)
# Keep headings in the original day (only headings ABOVE a moved block are "carried")
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
continue
# Start of an unchecked checkbox block
m_unchecked = unchecked_re.match(line)
if m_unchecked:
indent = m_unchecked.group(1) or ""
item_text = m_unchecked.group(2)
block: list[str] = [f"{indent}- [ ] {item_text}"]
i += 1
# Consume subsequent lines until the next *checked* checkbox
# (ignoring any "- [x]" that appear inside fenced code blocks)
block_in_fence = in_fence
block_fence_marker = fence_marker
while i < len(lines):
nxt = lines[i]
# If we're not inside a fence, a checked checkbox ends the block,
# otherwise a new heading does as well.
if not block_in_fence and (
checked_re.match(nxt) or heading_re.match(nxt)
):
break
# Normalize any unchecked checkbox lines inside the block
m_inner_unchecked = (
unchecked_re.match(nxt) if not block_in_fence else None
)
if m_inner_unchecked:
inner_indent = m_inner_unchecked.group(1) or ""
inner_text = m_inner_unchecked.group(2)
block.append(f"{inner_indent}- [ ] {inner_text}")
else:
block.append(nxt)
# Update fence state after consuming the line
block_in_fence, block_fence_marker = _update_fence_state(
nxt, block_in_fence, block_fence_marker
)
i += 1
# Carry the last heading *above* the unchecked checkbox
moved_blocks.append((current_heading, block))
moved_from_this_day = True
any_moved = True
# We consumed the block; keep scanning from the checked checkbox (or EOF)
continue
# Default: keep the line on the original day
remaining_lines.append(line)
in_fence, fence_marker = _update_fence_state(
line, in_fence, fence_marker
)
i += 1
if moved_from_this_day: if moved_from_this_day:
remaining_lines = _prune_empty_headings(remaining_lines)
modified_text = "\n".join(remaining_lines) modified_text = "\n".join(remaining_lines)
# Save the cleaned-up source day # Save the cleaned-up source day
self.db.save_new_version( self.db.save_new_version(
@ -999,33 +1222,52 @@ class MainWindow(QMainWindow):
if not any_moved: if not any_moved:
return False return False
# --- Merge all moved items into the *target* date --- # --- Merge all moved blocks into the *target* date ---
target_text = self.db.get_entry(target_iso) or "" target_text = self.db.get_entry(target_iso) or ""
target_lines = target_text.split("\n") if target_text else [] # Treat a whitespace-only target note as truly empty; otherwise we can
# end up appending the new heading *after* leading blank lines (e.g. if
# a newly-created empty day was previously saved as just "\n").
if not target_text.strip():
target_lines = []
else:
target_lines = target_text.split("\n")
by_heading: dict[tuple[int, str], list[str]] = {} by_heading: dict[tuple[int, str], list[list[str]]] = {}
plain_items: list[str] = [] plain_blocks: list[list[str]] = []
for heading_info, item_text in moved_items: for heading_info, block in moved_blocks:
todo_line = f"- [ ] {item_text}"
if heading_info is None: if heading_info is None:
# No heading above this checkbox in the source: behave as before plain_blocks.append(block)
plain_items.append(todo_line)
else: else:
by_heading.setdefault(heading_info, []).append(todo_line) by_heading.setdefault(heading_info, []).append(block)
# First insert all items that have headings # First insert all blocks that have headings
for (level, heading_text), todos in by_heading.items(): for (level, heading_text), blocks in by_heading.items():
target_lines = _insert_todos_under_heading( target_lines = _insert_blocks_under_heading(
target_lines, level, heading_text, todos target_lines, level, heading_text, blocks
) )
# Then append all items without headings at the end, like before # Then append all blocks without headings at the end, like before
if plain_items: if plain_blocks:
if target_lines and target_lines[-1].strip(): if target_lines and target_lines[-1].strip():
target_lines.append("") # one blank line before the "unsectioned" todos target_lines.append("") # one blank line before the "unsectioned" todos
target_lines.extend(plain_items) first = True
for block in plain_blocks:
if not block:
continue
if (
not first
and target_lines
and target_lines[-1].strip() != ""
and block[0].strip() != ""
and not (
_is_list_item(target_lines[-1]) and _is_list_item(block[0])
)
):
target_lines.append("")
target_lines.extend(block)
first = False
new_target_text = "\n".join(target_lines) new_target_text = "\n".join(target_lines)
if not new_target_text.endswith("\n"): if not new_target_text.endswith("\n"):
@ -1241,46 +1483,58 @@ class MainWindow(QMainWindow):
self._toolbar_bound = True self._toolbar_bound = True
def _sync_toolbar(self): def _sync_toolbar(self):
fmt = self.editor.currentCharFormat() """
Keep the toolbar "sticky" by reflecting the markdown state at the current caret/selection.
"""
c = self.editor.textCursor() c = self.editor.textCursor()
line = c.block().text()
# Inline styles (markdown-aware)
bold_on = bool(getattr(self.editor, "is_markdown_bold_active", lambda: False)())
italic_on = bool(
getattr(self.editor, "is_markdown_italic_active", lambda: False)()
)
strike_on = bool(
getattr(self.editor, "is_markdown_strike_active", lambda: False)()
)
# Block signals so setChecked() doesn't re-trigger actions # Block signals so setChecked() doesn't re-trigger actions
QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actBold)
QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actItalic)
QSignalBlocker(self.toolBar.actStrike) QSignalBlocker(self.toolBar.actStrike)
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actBold.setChecked(bold_on)
self.toolBar.actItalic.setChecked(fmt.fontItalic()) self.toolBar.actItalic.setChecked(italic_on)
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) self.toolBar.actStrike.setChecked(strike_on)
# Headings: decide which to check by current point size # Headings: infer from leading markdown markers
def _approx(a, b, eps=0.5): # small float tolerance heading_level = 0
return abs(float(a) - float(b)) <= eps m = re.match(r"^\s*(#{1,3})\s+", line)
if m:
cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() heading_level = len(m.group(1))
bH1 = _approx(cur_size, 24)
bH2 = _approx(cur_size, 18)
bH3 = _approx(cur_size, 14)
QSignalBlocker(self.toolBar.actH1) QSignalBlocker(self.toolBar.actH1)
QSignalBlocker(self.toolBar.actH2) QSignalBlocker(self.toolBar.actH2)
QSignalBlocker(self.toolBar.actH3) QSignalBlocker(self.toolBar.actH3)
QSignalBlocker(self.toolBar.actNormal) QSignalBlocker(self.toolBar.actNormal)
self.toolBar.actH1.setChecked(bH1) self.toolBar.actH1.setChecked(heading_level == 1)
self.toolBar.actH2.setChecked(bH2) self.toolBar.actH2.setChecked(heading_level == 2)
self.toolBar.actH3.setChecked(bH3) self.toolBar.actH3.setChecked(heading_level == 3)
self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) self.toolBar.actNormal.setChecked(heading_level == 0)
# Lists: infer from leading markers on the current line
bullets_on = bool(re.match(r"^\s*(?:•|-|\*)\s+", line))
numbers_on = bool(re.match(r"^\s*\d+\.\s+", line))
checkboxes_on = bool(re.match(r"^\s*[☐☑]\s+", line))
# Lists
lst = c.currentList()
bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
QSignalBlocker(self.toolBar.actBullets) QSignalBlocker(self.toolBar.actBullets)
QSignalBlocker(self.toolBar.actNumbers) QSignalBlocker(self.toolBar.actNumbers)
self.toolBar.actBullets.setChecked(bool(bullets_on)) QSignalBlocker(self.toolBar.actCheckboxes)
self.toolBar.actNumbers.setChecked(bool(numbers_on))
self.toolBar.actBullets.setChecked(bullets_on)
self.toolBar.actNumbers.setChecked(numbers_on)
self.toolBar.actCheckboxes.setChecked(checkboxes_on)
def _change_font_size(self, delta: int) -> None: def _change_font_size(self, delta: int) -> None:
"""Change font size for all editor tabs and save the setting.""" """Change font size for all editor tabs and save the setting."""

View file

@ -46,7 +46,7 @@ class MarkdownEditor(QTextEdit):
_COLLAPSE_LABEL_COLLAPSE = "collapse" _COLLAPSE_LABEL_COLLAPSE = "collapse"
_COLLAPSE_LABEL_EXPAND = "expand" _COLLAPSE_LABEL_EXPAND = "expand"
_COLLAPSE_END_MARKER = "<!-- bouquin:collapse:end -->" _COLLAPSE_END_MARKER = "<!-- bouquin:collapse:end -->"
# Accept either "collapse" or "expand" in the header text (older files used only "collapse") # Accept either "collapse" or "expand" in the header text
_COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$")
_COLLAPSE_END_RE = re.compile(r"^([ \t]*)<!--\s*bouquin:collapse:end\s*-->\s*$") _COLLAPSE_END_RE = re.compile(r"^([ \t]*)<!--\s*bouquin:collapse:end\s*-->\s*$")
@ -114,6 +114,9 @@ class MarkdownEditor(QTextEdit):
# Track if we're currently updating text programmatically # Track if we're currently updating text programmatically
self._updating = False self._updating = False
# Track pending inline marker insertion (e.g. Italic with no selection)
self._pending_inline_marker: str | None = None
# Help avoid double-click selecting of checkbox # Help avoid double-click selecting of checkbox
self._suppress_next_checkbox_double_click = False self._suppress_next_checkbox_double_click = False
@ -928,6 +931,69 @@ class MarkdownEditor(QTextEdit):
return None return None
def _maybe_skip_over_marker_run(self, key: Qt.Key) -> bool:
"""Skip over common markdown marker runs when navigating with Left/Right.
This prevents the caret from landing *inside* runs like '**', '***', '__', '___' or '~~',
which can cause temporary toolbar-state flicker and makes navigation feel like it takes
"two presses" to get past closing markers.
Hold any modifier key (Shift/Ctrl/Alt/Meta) to disable this behavior.
"""
c = self.textCursor()
if c.hasSelection():
return False
p = c.position()
doc_max = self._doc_max_pos()
# Right: run starts at the caret
if key == Qt.Key.Key_Right:
if p >= doc_max:
return False
ch = self._text_range(p, p + 1)
if ch not in ("*", "_", "~"):
return False
run = 0
while p + run < doc_max and self._text_range(p + run, p + run + 1) == ch:
run += 1
# Only skip multi-char runs (bold/strong/emphasis runs or strike)
if ch in ("*", "_") and run >= 2:
c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, run)
self.setTextCursor(c)
return True
if ch == "~" and run == 2:
c.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2)
self.setTextCursor(c)
return True
return False
# Left: run ends at the caret
if key == Qt.Key.Key_Left:
if p <= 0:
return False
ch = self._text_range(p - 1, p)
if ch not in ("*", "_", "~"):
return False
run = 0
while p - 1 - run >= 0 and self._text_range(p - 1 - run, p - run) == ch:
run += 1
if ch in ("*", "_") and run >= 2:
c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, run)
self.setTextCursor(c)
return True
if ch == "~" and run == 2:
c.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 2)
self.setTextCursor(c)
return True
return False
def keyPressEvent(self, event): def keyPressEvent(self, event):
"""Handle special key events for markdown editing.""" """Handle special key events for markdown editing."""
c = self.textCursor() c = self.textCursor()
@ -936,7 +1002,6 @@ class MarkdownEditor(QTextEdit):
in_code = self._is_inside_code_block(block) in_code = self._is_inside_code_block(block)
is_fence_line = block.text().strip().startswith("```") is_fence_line = block.text().strip().startswith("```")
# --- NEW: 3rd backtick shortcut → open code block dialog ---
# Only when we're *not* already in a code block or on a fence line. # Only when we're *not* already in a code block or on a fence line.
if event.text() == "`" and not (in_code or is_fence_line): if event.text() == "`" and not (in_code or is_fence_line):
line = block.text() line = block.text()
@ -1002,6 +1067,14 @@ class MarkdownEditor(QTextEdit):
super().keyPressEvent(event) super().keyPressEvent(event)
return return
if (
event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right)
and event.modifiers() == Qt.KeyboardModifier.NoModifier
and not self.textCursor().hasSelection()
):
if self._maybe_skip_over_marker_run(event.key()):
return
# --- Step out of a code block with Down at EOF --- # --- Step out of a code block with Down at EOF ---
if event.key() == Qt.Key.Key_Down: if event.key() == Qt.Key.Key_Down:
c = self.textCursor() c = self.textCursor()
@ -1509,19 +1582,389 @@ class MarkdownEditor(QTextEdit):
# ------------------------ Toolbar action handlers ------------------------ # ------------------------ Toolbar action handlers ------------------------
# ------------------------ Inline markdown helpers ------------------------
def _doc_max_pos(self) -> int:
# QTextDocument includes a trailing null character; cursor positions stop before it.
doc = self.document()
return max(0, doc.characterCount() - 1)
def _text_range(self, start: int, end: int) -> str:
"""Return document text between [start, end) using QTextCursor indexing."""
doc_max = self._doc_max_pos()
start = max(0, min(start, doc_max))
end = max(0, min(end, doc_max))
if end < start:
start, end = end, start
tc = QTextCursor(self.document())
tc.setPosition(start)
tc.setPosition(end, QTextCursor.KeepAnchor)
return tc.selectedText()
def _selection_wrapped_by(
self,
markers: tuple[str, ...],
*,
require_singletons: bool = False,
) -> str | None:
"""
If the current selection is wrapped by any marker in `markers`, return the marker.
Supports both cases:
1) the selection itself includes the markers, e.g. "**bold**"
2) the selection is the inner text, with markers immediately adjacent in the doc.
"""
c = self.textCursor()
if not c.hasSelection():
return None
sel = c.selectedText()
start = c.selectionStart()
end = c.selectionEnd()
doc_max = self._doc_max_pos()
# Case 1: selection includes markers
for m in markers:
lm = len(m)
if len(sel) >= 2 * lm and sel.startswith(m) and sel.endswith(m):
return m
# Case 2: markers adjacent to selection
for m in markers:
lm = len(m)
if start < lm or end + lm > doc_max:
continue
before = self._text_range(start - lm, start)
after = self._text_range(end, end + lm)
if before != m or after != m:
continue
if require_singletons and lm == 1:
# Ensure the single marker isn't part of a double/triple (e.g. "**" or "__")
ch = m
left_marker_pos = start - 1
right_marker_pos = end
if (
left_marker_pos - 1 >= 0
and self._text_range(left_marker_pos - 1, left_marker_pos) == ch
):
continue
if (
right_marker_pos + 1 <= doc_max
and self._text_range(right_marker_pos + 1, right_marker_pos + 2)
== ch
):
continue
return m
return None
def _caret_between_markers(
self, marker: str, *, require_singletons: bool = False
) -> bool:
"""True if the caret is exactly between an opening and closing marker (e.g. **|**)."""
c = self.textCursor()
if c.hasSelection():
return False
p = c.position()
lm = len(marker)
doc_max = self._doc_max_pos()
if p < lm or p + lm > doc_max:
return False
before = self._text_range(p - lm, p)
after = self._text_range(p, p + lm)
if before != marker or after != marker:
return False
if require_singletons and lm == 1:
# Disallow if either side is adjacent to the same char (part of "**", "__", "***", etc.)
ch = marker
if p - 2 >= 0 and self._text_range(p - 2, p - 1) == ch:
return False
if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch:
return False
return True
def _caret_before_marker(
self, marker: str, *, require_singletons: bool = False
) -> bool:
"""True if the caret is immediately before `marker` (e.g. |**)."""
c = self.textCursor()
if c.hasSelection():
return False
p = c.position()
lm = len(marker)
doc_max = self._doc_max_pos()
if p + lm > doc_max:
return False
after = self._text_range(p, p + lm)
if after != marker:
return False
if require_singletons and lm == 1:
# Disallow if it's part of a run like "**" or "___".
ch = marker
if p - 1 >= 0 and self._text_range(p - 1, p) == ch:
return False
if p + 1 <= doc_max and self._text_range(p + 1, p + 2) == ch:
return False
return True
def _unwrap_selection(
self, marker: str, *, replacement_marker: str | None = None
) -> bool:
"""
Remove `marker` wrapping from the selection.
If replacement_marker is provided, replace marker with that (e.g. ***text*** -> *text*).
"""
c = self.textCursor()
if not c.hasSelection():
return False
sel = c.selectedText()
start = c.selectionStart()
end = c.selectionEnd()
lm = len(marker)
doc_max = self._doc_max_pos()
def _select_inner(
edit_cursor: QTextCursor, inner_start: int, inner_len: int
) -> None:
edit_cursor.setPosition(inner_start)
edit_cursor.setPosition(inner_start + inner_len, QTextCursor.KeepAnchor)
self.setTextCursor(edit_cursor)
# Case 1: selection includes markers
if len(sel) >= 2 * lm and sel.startswith(marker) and sel.endswith(marker):
inner = sel[lm:-lm]
new_text = (
f"{replacement_marker}{inner}{replacement_marker}"
if replacement_marker is not None
else inner
)
c.beginEditBlock()
c.insertText(new_text)
c.endEditBlock()
# Re-select the inner content (not the markers)
inner_start = c.position() - len(new_text)
if replacement_marker is not None:
inner_start += len(replacement_marker)
_select_inner(c, inner_start, len(inner))
return True
# Case 2: marker is adjacent to selection
if start >= lm and end + lm <= doc_max:
before = self._text_range(start - lm, start)
after = self._text_range(end, end + lm)
if before == marker and after == marker:
new_text = (
f"{replacement_marker}{sel}{replacement_marker}"
if replacement_marker is not None
else sel
)
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(start - lm)
edit.setPosition(end + lm, QTextCursor.KeepAnchor)
edit.insertText(new_text)
edit.endEditBlock()
inner_start = (start - lm) + (
len(replacement_marker) if replacement_marker else 0
)
_select_inner(edit, inner_start, len(sel))
return True
return False
def _wrap_selection(self, marker: str) -> None:
"""Wrap the current selection with `marker` and keep the content selected."""
c = self.textCursor()
if not c.hasSelection():
return
sel = c.selectedText()
start = c.selectionStart()
lm = len(marker)
c.beginEditBlock()
c.insertText(f"{marker}{sel}{marker}")
c.endEditBlock()
# Re-select the original content
edit = QTextCursor(self.document())
edit.setPosition(start + lm)
edit.setPosition(start + lm + len(sel), QTextCursor.KeepAnchor)
self.setTextCursor(edit)
def _pos_inside_inline_span(
self,
patterns: list[tuple[re.Pattern, int]],
start_in_block: int,
end_in_block: int,
) -> bool:
"""True if [start_in_block, end_in_block] lies within the content region of any pattern match."""
block_text = self.textCursor().block().text()
for pat, mlen in patterns:
for m in pat.finditer(block_text):
s, e = m.span()
cs, ce = s + mlen, e - mlen
if cs <= start_in_block and end_in_block <= ce:
return True
return False
def is_markdown_bold_active(self) -> bool:
c = self.textCursor()
bold_markers = ("***", "___", "**", "__")
if c.hasSelection():
if self._selection_wrapped_by(bold_markers) is not None:
return True
block = c.block()
start_in_block = c.selectionStart() - block.position()
end_in_block = c.selectionEnd() - block.position()
patterns = [
(re.compile(r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*"), 3),
(re.compile(r"(?<!_)___(.+?)(?<!_)___"), 3),
(re.compile(r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)"), 2),
(re.compile(r"(?<!_)__(?!_)(.+?)(?<!_)__(?!_)"), 2),
]
return self._pos_inside_inline_span(patterns, start_in_block, end_in_block)
# Caret (no selection)
if any(self._caret_between_markers(m) for m in ("**", "__")):
return True
block = c.block()
pos_in_block = c.position() - block.position()
patterns = [
(re.compile(r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*"), 3),
(re.compile(r"(?<!_)___(.+?)(?<!_)___"), 3),
(re.compile(r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)"), 2),
(re.compile(r"(?<!_)__(?!_)(.+?)(?<!_)__(?!_)"), 2),
]
return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block)
def is_markdown_italic_active(self) -> bool:
c = self.textCursor()
italic_markers = ("*", "_", "***", "___")
if c.hasSelection():
if (
self._selection_wrapped_by(italic_markers, require_singletons=True)
is not None
):
return True
block = c.block()
start_in_block = c.selectionStart() - block.position()
end_in_block = c.selectionEnd() - block.position()
patterns = [
(re.compile(r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*"), 3),
(re.compile(r"(?<!_)___(.+?)(?<!_)___"), 3),
(re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"), 1),
(re.compile(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)"), 1),
]
return self._pos_inside_inline_span(patterns, start_in_block, end_in_block)
pending = getattr(self, "_pending_inline_marker", None)
if pending in ("*", "_") and self._caret_between_markers(
pending, require_singletons=True
):
return True
if pending in ("*", "_"):
# caret moved away from the empty pair; stop treating it as "pending"
self._pending_inline_marker = None
block = c.block()
pos_in_block = c.position() - block.position()
patterns = [
(re.compile(r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*"), 3),
(re.compile(r"(?<!_)___(.+?)(?<!_)___"), 3),
(re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"), 1),
(re.compile(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)"), 1),
]
return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block)
def is_markdown_strike_active(self) -> bool:
c = self.textCursor()
if c.hasSelection():
if self._selection_wrapped_by(("~~",)) is not None:
return True
block = c.block()
start_in_block = c.selectionStart() - block.position()
end_in_block = c.selectionEnd() - block.position()
patterns = [(re.compile(r"~~(.+?)~~"), 2)]
return self._pos_inside_inline_span(patterns, start_in_block, end_in_block)
if self._caret_between_markers("~~"):
return True
block = c.block()
pos_in_block = c.position() - block.position()
patterns = [(re.compile(r"~~(.+?)~~"), 2)]
return self._pos_inside_inline_span(patterns, pos_in_block, pos_in_block)
# ------------------------ Toolbar action handlers ------------------------
def apply_weight(self): def apply_weight(self):
"""Toggle bold formatting.""" """Toggle bold formatting (markdown ** / __, and *** / ___)."""
cursor = self.textCursor() cursor = self.textCursor()
if cursor.hasSelection(): if cursor.hasSelection():
selected = cursor.selectedText() # If bold+italic, toggling bold should leave italic: ***text*** -> *text*
# Check if already bold m = self._selection_wrapped_by(("***", "___"))
if selected.startswith("**") and selected.endswith("**"): if m is not None:
# Remove bold repl = "*" if m == "***" else "_"
new_text = selected[2:-2] if self._unwrap_selection(m, replacement_marker=repl):
else: self.setFocus()
# Add bold return
new_text = f"**{selected}**"
cursor.insertText(new_text) # Normal bold: **text** / __text__
m = self._selection_wrapped_by(("**", "__"))
if m is not None:
if self._unwrap_selection(m):
self.setFocus()
return
# Not bold: wrap selection with **
self._wrap_selection("**")
self.setFocus()
return
# No selection:
# - If we're between an empty pair (**|**), remove them.
# - If we're inside bold and sitting right before the closing marker (**text|**),
# jump the caret *past* the marker (end-bold) instead of inserting more.
# - Otherwise, insert a new empty pair and place the caret between.
if self._caret_between_markers("**") or self._caret_between_markers("__"):
marker = "**" if self._caret_between_markers("**") else "__"
p = cursor.position()
lm = len(marker)
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(p - lm)
edit.setPosition(p + lm, QTextCursor.KeepAnchor)
edit.insertText("")
edit.endEditBlock()
edit.setPosition(p - lm)
self.setTextCursor(edit)
elif self.is_markdown_bold_active() and (
self._caret_before_marker("**") or self._caret_before_marker("__")
):
marker = "**" if self._caret_before_marker("**") else "__"
cursor.movePosition(
QTextCursor.MoveOperation.Right,
QTextCursor.MoveMode.MoveAnchor,
len(marker),
)
self.setTextCursor(cursor)
self._pending_inline_marker = None
else: else:
# No selection - just insert markers # No selection - just insert markers
cursor.insertText("****") cursor.insertText("****")
@ -1529,44 +1972,120 @@ class MarkdownEditor(QTextEdit):
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2 QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
) )
self.setTextCursor(cursor) self.setTextCursor(cursor)
self._pending_inline_marker = "*"
# Return focus to editor # Return focus to editor
self.setFocus() self.setFocus()
def apply_italic(self): def apply_italic(self):
"""Toggle italic formatting.""" """Toggle italic formatting (markdown * / _, and *** / ___)."""
cursor = self.textCursor() cursor = self.textCursor()
if cursor.hasSelection(): if cursor.hasSelection():
selected = cursor.selectedText() # If bold+italic, toggling italic should leave bold: ***text*** -> **text**
if ( m = self._selection_wrapped_by(("***", "___"))
selected.startswith("*") if m is not None:
and selected.endswith("*") repl = "**" if m == "***" else "__"
and not selected.startswith("**") if self._unwrap_selection(m, replacement_marker=repl):
): self.setFocus()
new_text = selected[1:-1] return
else:
new_text = f"*{selected}*" m = self._selection_wrapped_by(("*", "_"), require_singletons=True)
cursor.insertText(new_text) if m is not None:
if self._unwrap_selection(m):
self.setFocus()
return
self._wrap_selection("*")
self.setFocus()
return
# No selection:
# - If we're between an empty pair (*|*), remove them.
# - If we're inside italic and sitting right before the closing marker (*text|*),
# jump the caret past the marker (end-italic) instead of inserting more.
# - Otherwise, insert a new empty pair and place the caret between.
if self._caret_between_markers(
"*", require_singletons=True
) or self._caret_between_markers("_", require_singletons=True):
marker = (
"*"
if self._caret_between_markers("*", require_singletons=True)
else "_"
)
p = cursor.position()
lm = len(marker)
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(p - lm)
edit.setPosition(p + lm, QTextCursor.KeepAnchor)
edit.insertText("")
edit.endEditBlock()
edit.setPosition(p - lm)
self.setTextCursor(edit)
self._pending_inline_marker = None
elif self.is_markdown_italic_active() and (
self._caret_before_marker("*", require_singletons=True)
or self._caret_before_marker("_", require_singletons=True)
):
marker = (
"*" if self._caret_before_marker("*", require_singletons=True) else "_"
)
cursor.movePosition(
QTextCursor.MoveOperation.Right,
QTextCursor.MoveMode.MoveAnchor,
len(marker),
)
self.setTextCursor(cursor)
self._pending_inline_marker = None
else: else:
cursor.insertText("**") cursor.insertText("**")
cursor.movePosition( cursor.movePosition(
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1 QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
) )
self.setTextCursor(cursor) self.setTextCursor(cursor)
self._pending_inline_marker = "*"
# Return focus to editor # Return focus to editor
self.setFocus() self.setFocus()
def apply_strikethrough(self): def apply_strikethrough(self):
"""Toggle strikethrough formatting.""" """Toggle strikethrough formatting (markdown ~~)."""
cursor = self.textCursor() cursor = self.textCursor()
if cursor.hasSelection(): if cursor.hasSelection():
selected = cursor.selectedText() m = self._selection_wrapped_by(("~~",))
if selected.startswith("~~") and selected.endswith("~~"): if m is not None:
new_text = selected[2:-2] if self._unwrap_selection(m):
else: self.setFocus()
new_text = f"~~{selected}~~" return
cursor.insertText(new_text) self._wrap_selection("~~")
self.setFocus()
return
# No selection:
# - If we're between an empty pair (~~|~~), remove them.
# - If we're inside strike and sitting right before the closing marker (~~text|~~),
# jump the caret past the marker (end-strike) instead of inserting more.
# - Otherwise, insert a new empty pair and place the caret between.
if self._caret_between_markers("~~"):
p = cursor.position()
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(p - 2)
edit.setPosition(p + 2, QTextCursor.KeepAnchor)
edit.insertText("")
edit.endEditBlock()
edit.setPosition(p - 2)
self.setTextCursor(edit)
elif self.is_markdown_strike_active() and self._caret_before_marker("~~"):
cursor.movePosition(
QTextCursor.MoveOperation.Right,
QTextCursor.MoveMode.MoveAnchor,
2,
)
self.setTextCursor(cursor)
self._pending_inline_marker = None
else: else:
cursor.insertText("~~~~") cursor.insertText("~~~~")
cursor.movePosition( cursor.movePosition(

View file

@ -98,6 +98,7 @@ class ToolBar(QToolBar):
self.actNumbers.triggered.connect(self.numbersRequested) self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self) self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes"))
self.actCheckboxes.setCheckable(True)
self.actCheckboxes.triggered.connect(self.checkboxesRequested) self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images # Images
@ -126,22 +127,14 @@ class ToolBar(QToolBar):
self.actDocuments = QAction("📁", self) self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents")) self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested) self.actDocuments.triggered.connect(self.documentsRequested)
# Headings are mutually exclusive (like radio buttons)
# Set exclusive buttons in QActionGroups
self.grpHeadings = QActionGroup(self) self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True) self.grpHeadings.setExclusive(True)
for a in ( for a in (self.actH1, self.actH2, self.actH3, self.actNormal):
self.actBold,
self.actItalic,
self.actStrike,
self.actH1,
self.actH2,
self.actH3,
self.actNormal,
):
a.setCheckable(True) a.setCheckable(True)
a.setActionGroup(self.grpHeadings) a.setActionGroup(self.grpHeadings)
# List types are mutually exclusive
self.grpLists = QActionGroup(self) self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True) self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes): for a in (self.actBullets, self.actNumbers, self.actCheckboxes):

21
debian/changelog vendored
View file

@ -1,3 +1,24 @@
bouquin (0.8.3) unstable; urgency=medium
* Update urllib3 dependency to resolve CVE-2026-21441
* Fix carrying over data to next day from over-capturing data belonging to next header section
* Other dependency updates
-- Miguel Jacq <mig@mig5.net> Fri, 30 Jan 2026 16:48:00 +1100
bouquin (0.8.2) unstable; urgency=medium
* Add ability to delete an invoice via 'Manage Invoices' dialog
-- Miguel Jacq <mig@mig5.net> Wed, 31 Dec 2025 16:00:00 +1100
bouquin (0.8.1) unstable; urgency=medium
* Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
* Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
-- Miguel Jacq <mig@mig5.net> Tue, 26 Dec 2025 18:00:00 +1100
bouquin (0.8.0) unstable; urgency=medium bouquin (0.8.0) unstable; urgency=medium
* Add .desktop file for Debian * Add .desktop file for Debian

305
poetry.lock generated
View file

@ -14,13 +14,13 @@ files = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2026.1.4"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
] ]
[[package]] [[package]]
@ -158,103 +158,103 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.0" version = "7.13.2"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"},
{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.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"},
{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.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"},
{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.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"},
{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.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"},
{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.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"},
{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.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"},
{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.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"},
{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.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"},
{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.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"},
{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.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"},
{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.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"},
{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.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"},
{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.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"},
{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.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"},
{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.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"},
{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.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"},
{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.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"},
{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.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"},
{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.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"},
{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.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"},
{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.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"},
] ]
[package.dependencies] [package.dependencies]
@ -321,28 +321,28 @@ files = [
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "3.10" version = "3.10.1"
description = "Python implementation of John Gruber's Markdown." description = "Python implementation of John Gruber's Markdown."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, {file = "markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"},
{file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, {file = "markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a"},
] ]
[package.extras] [package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
testing = ["coverage", "pyyaml"] testing = ["coverage", "pyyaml"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "26.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
] ]
[[package]] [[package]]
@ -560,53 +560,58 @@ files = [
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.3.0" version = "2.4.0"
description = "A lil' TOML parser" description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"},
{file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"},
{file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"},
{file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"},
{file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"},
{file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"},
] ]
[[package]] [[package]]
@ -622,13 +627,13 @@ files = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.2" version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
] ]
[package.extras] [package.extras]
@ -640,4 +645,4 @@ zstd = ["backports-zstd (>=1.0.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "0241cd7378c45e79da728a23b89defa18f776ada9af1e60f2a19b0d90f3a2c19" content-hash = "0ce61476b8c35f864d395ab3a4ee4645a1ad2e16aa800cba5e77e5bfee23d6a6"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.8.0" version = "0.8.3"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"
@ -13,8 +13,8 @@ include = ["bouquin/locales/*.json", "bouquin/keys/mig5.asc", "bouquin/fonts/Not
python = ">=3.10,<3.14" python = ">=3.10,<3.14"
pyside6 = ">=6.8.1,<7.0.0" pyside6 = ">=6.8.1,<7.0.0"
bouquin-sqlcipher4 = "^4.12.0" bouquin-sqlcipher4 = "^4.12.0"
requests = "^2.32.5" requests = "^2.32.3"
markdown = "^3.10" markdown = "^3.7"
[tool.poetry.scripts] [tool.poetry.scripts]
bouquin = "bouquin.__main__:main" bouquin = "bouquin.__main__:main"

View file

@ -69,4 +69,50 @@ for dist in ${DISTS[@]}; do
reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}" reprepro -b /home/user/git/repo includedeb "${release}" "${debfile}"
done done
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
# RPM
sudo apt-get -y install createrepo-c rpm
BUILD_OUTPUT="${HOME}/git/bouquin/dist"
KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="letessier.mig5.net:/opt/repo_rpm"
DISTS=(
fedora:42
)
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
REPO_RELEASE_ROOT="${REPO_ROOT}/${release}"
RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64"
mkdir -p "$RPM_REPO"
docker build \
--no-cache \
-f Dockerfile.rpmbuild \
-t bouquin-rpm:${release} \
--progress=plain \
--build-arg BASE_IMAGE=${dist} \
.
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/bouquin-sqlcipher4/dist/rpm":/deps:ro bouquin-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
done
cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
createrepo_c "$RPM_REPO"
echo "==> Signing repomd.xml..."
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
done
echo "==> Syncing repo to server..."
rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/"
echo "Done!"
ssh lupin.mig5.net "echo ${VERSION} | tee /var/www/bouquin/version.txt"

95
rpm/bouquin.spec Normal file
View file

@ -0,0 +1,95 @@
# bouquin Fedora 42 RPM spec using Fedora's pyproject RPM macros (Poetry backend).
#
# NOTE: Bouquin depends on "bouquin-sqlcipher4" project, but the RPM actually
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
# pyproject.toml at build time.
%global upstream_version 0.8.3
Name: bouquin
Version: %{upstream_version}
Release: 1%{?dist}.bouquin1
Summary: A simple, opinionated notebook application (Python/Qt/SQLCipher)
License: GPL-3.0-or-later
URL: https://git.mig5.net/mig5/bouquin
Source0: %{name}-%{version}.tar.gz
BuildArch: noarch
BuildRequires: pyproject-rpm-macros
BuildRequires: python3-devel
BuildRequires: python3-poetry-core
BuildRequires: desktop-file-utils
# Non-Python runtime dep (Fedora equivalent of Debian's libxcb-cursor0)
Requires: xcb-util-cursor
# Make sure private repo dependency is pulled in by package name as well.
Requires: python3-sqlcipher4 >= 4.12.0
%description
Bouquin is a simple, opinionated notebook application written in Python and Qt,
storing data using SQLCipher.
%prep
%autosetup -n bouquin
# Patch dependency name so Fedora's python dependency generator targets the
# provider from bouquin-sqlcipher4 RPM (python3dist(sqlcipher4)).
%{python3} - <<'PY'
from pathlib import Path
import re
p = Path("pyproject.toml")
txt = p.read_text(encoding="utf-8")
pattern = re.compile(r'(?ms)(^\[tool\.poetry\.dependencies\]\n.*?)(^\[|\Z)')
m = pattern.search(txt)
if not m:
raise SystemExit("Could not locate [tool.poetry.dependencies] in pyproject.toml")
deps_block = m.group(1)
deps_block2 = re.sub(r'(?m)^bouquin-sqlcipher4\s*=\s*(".*?")\s*$', r'sqlcipher4 = \1', deps_block)
if deps_block == deps_block2:
raise SystemExit("Did not find bouquin-sqlcipher4 dependency to rewrite")
p.write_text(txt[:m.start(1)] + deps_block2 + txt[m.end(1):], encoding="utf-8")
PY
desktop-file-validate debian/bouquin.desktop
%generate_buildrequires
%pyproject_buildrequires
%build
%pyproject_wheel
%install
%pyproject_install
%pyproject_save_files bouquin
# Desktop integration (mirrors debian/bouquin.install)
install -Dpm 0644 debian/bouquin.desktop %{buildroot}%{_datadir}/applications/bouquin.desktop
install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%files -f %{pyproject_files}
%license LICENSE
%doc README.md CHANGELOG.md
%{_bindir}/bouquin
%{_datadir}/applications/bouquin.desktop
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%changelog
* Fri Jan 30 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Update urllib3 dependency to resolve CVE-2026-21441
- Fix carrying over data to next day from over-capturing data belonging to next header section
- Other dependency updates
* Wed Dec 31 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Add ability to delete an invoice via 'Manage Invoices' dialog
* Fri Dec 26 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Fix bold/italic/strikethrough styling in certain conditions when toolbar action is used.
- Move a code block or collapsed section (or generally, anything after a checkbox line until the next checkbox line) when moving an unchecked checkbox line to another day.
* Wed Dec 24 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Initial RPM packaging for Fedora 42

View file

@ -1,6 +1,7 @@
import base64 import base64
import pytest import pytest
import re
from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.theme import Theme, ThemeConfig, ThemeManager
@ -72,8 +73,13 @@ def test_apply_styles_and_headings(editor, qtbot):
editor.apply_italic() editor.apply_italic()
editor.apply_strikethrough() editor.apply_strikethrough()
editor.apply_heading(24) editor.apply_heading(24)
md = editor.to_markdown() md = editor.to_markdown().strip()
assert "**" in md and "*~~~~*" in md
assert md.startswith("# ")
assert "~~hello world~~" in md
assert re.search(
r"\*{2,3}~~hello world~~\*{2,3}", md
) # bold or bold+italic wrapping strike
def test_toggle_lists_and_checkboxes(editor): def test_toggle_lists_and_checkboxes(editor):