Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bb61273da | |||
| 7f2c88f52b | |||
| 9f399c589d | |||
| dd1ae74b19 | |||
| 5f89c4286e | |||
| 7bb2746a0f | |||
| b192264dbf | |||
| f8aab05cb7 | |||
| 04f67a786f | |||
| 827565838f | |||
| dce124e083 | |||
| 7e47cef602 | |||
| 9c7cb7ba2b | |||
| 2eba0df85a | |||
| 48e18e0408 | |||
| 26c136900e | |||
| 9b457278f9 |
16 changed files with 1430 additions and 267 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
110
Dockerfile.rpmbuild
Normal 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"]
|
||||||
19
README.md
19
README.md
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
21
debian/changelog
vendored
|
|
@ -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
305
poetry.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
48
release.sh
48
release.sh
|
|
@ -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
95
rpm/bouquin.spec
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue