Compare commits
4 commits
4ff4d24b42
...
807d11ca75
| Author | SHA1 | Date | |
|---|---|---|---|
| 807d11ca75 | |||
| 757517dcc4 | |||
| df6ea8d139 | |||
| 426142c0c3 |
11 changed files with 1023 additions and 366 deletions
80
.forgejo/workflows/build-deb.yml
Normal file
80
.forgejo/workflows/build-deb.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl gnupg2 ca-certificates
|
||||||
|
mkdir -p /usr/share/keyrings
|
||||||
|
curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor -o /usr/share/keyrings/mig5.gpg
|
||||||
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net trixie main" | tee /etc/apt/sources.list.d/mig5.list
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
devscripts \
|
||||||
|
debhelper \
|
||||||
|
dh-python \
|
||||||
|
python3-all-dev \
|
||||||
|
python3-setuptools \
|
||||||
|
python3-wheel \
|
||||||
|
libssl-dev \
|
||||||
|
rsync \
|
||||||
|
pybuild-plugin-pyproject \
|
||||||
|
python3-poetry-core \
|
||||||
|
python3-sqlcipher4 \
|
||||||
|
python3-pyside6.qtwidgets \
|
||||||
|
python3-pyside6.qtcore \
|
||||||
|
python3-pyside6.qtgui \
|
||||||
|
python3-pyside6.qtsvg \
|
||||||
|
python3-pyside6.qtprintsupport \
|
||||||
|
python3-requests \
|
||||||
|
python3-markdown \
|
||||||
|
libxcb-cursor0 \
|
||||||
|
fonts-noto-core
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Build deb
|
||||||
|
run: |
|
||||||
|
mkdir /out
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.venv' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
--exclude 'build' \
|
||||||
|
--exclude '__pycache__' \
|
||||||
|
--exclude '.pytest_cache' \
|
||||||
|
--exclude '.mypy_cache' \
|
||||||
|
./ /out/
|
||||||
|
|
||||||
|
cd /out/
|
||||||
|
export DEBEMAIL="mig@mig5.net"
|
||||||
|
export DEBFULLNAME="Miguel Jacq"
|
||||||
|
|
||||||
|
dch --distribution "trixie" --local "~trixie" "CI build for trixie"
|
||||||
|
dpkg-buildpackage -us -uc -b
|
||||||
|
|
||||||
|
# Notify if any previous step in this job failed
|
||||||
|
- name: Notify on failure
|
||||||
|
if: ${{ failure() }}
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
|
||||||
|
REPOSITORY: ${{ forgejo.repository }}
|
||||||
|
RUN_NUMBER: ${{ forgejo.run_number }}
|
||||||
|
SERVER_URL: ${{ forgejo.server_url }}
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
|
||||||
|
"$WEBHOOK_URL"
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
# 0.7.6
|
# 0.8.0
|
||||||
|
|
||||||
* Add .desktop file for Debian
|
* Add .desktop file for Debian
|
||||||
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter.
|
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter.
|
||||||
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
|
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
|
||||||
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
|
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
|
||||||
|
* Add ability to collapse/expand sections of text.
|
||||||
|
* Add 'Last Month' date range for timesheet reports
|
||||||
|
* Add missing strings (for English and French)
|
||||||
|
* Don't offer to download latest AppImage unless we are running as an AppImage already
|
||||||
|
|
||||||
# 0.7.5
|
# 0.7.5
|
||||||
|
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -12,6 +12,11 @@ It is designed to treat each day as its own 'page', complete with Markdown rende
|
||||||
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
|
search, reminders and time logging for those of us who need to keep track of not just TODOs, but
|
||||||
also how long we spent on them.
|
also how long we spent on them.
|
||||||
|
|
||||||
|
For those who rely on that time logging for work, there is also an Invoicing feature that can
|
||||||
|
generate invoices of that time spent.
|
||||||
|
|
||||||
|
There is also support for embedding documents in a file manager.
|
||||||
|
|
||||||
It uses SQLCipher as a drop-in replacement for SQLite3.
|
It uses SQLCipher as a drop-in replacement for SQLite3.
|
||||||
|
|
||||||
This means that the underlying database for the notebook is encrypted at rest.
|
This means that the underlying database for the notebook is encrypted at rest.
|
||||||
|
|
@ -52,16 +57,18 @@ report from within the app, or optionally to check for new versions to upgrade t
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## Some of the features
|
## Features
|
||||||
|
|
||||||
* Data is encrypted at rest
|
* Data is encrypted at rest
|
||||||
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
||||||
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
|
* All changes are version controlled, with ability to view/diff versions, revert or delete revisions
|
||||||
* Automatic rendering of basic Markdown syntax
|
|
||||||
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
||||||
|
* Automatic rendering of basic Markdown syntax
|
||||||
|
* Basic code block editing/highlighting
|
||||||
|
* Ability to collapse/expand sections of text
|
||||||
|
* Ability to increase/decrease font size
|
||||||
* Images are supported
|
* Images are supported
|
||||||
* Search all pages, or find text on current page
|
* Search all pages, or find text on current page
|
||||||
* Add and manage tags
|
|
||||||
* Automatic periodic saving (or explicitly save)
|
* Automatic periodic saving (or explicitly save)
|
||||||
* Automatic locking of the app after a period of inactivity (default 15 min)
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
||||||
* Rekey the database (change the password)
|
* Rekey the database (change the password)
|
||||||
|
|
@ -69,11 +76,12 @@ report from within the app, or optionally to check for new versions to upgrade t
|
||||||
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
||||||
* Dark and light theme support
|
* Dark and light theme support
|
||||||
* Automatically generate checkboxes when typing 'TODO'
|
* Automatically generate checkboxes when typing 'TODO'
|
||||||
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday.
|
* It is possible to automatically move unchecked checkboxes from the last 7 days to the next day.
|
||||||
* English, French and Italian locales provided
|
* English, French and Italian locales provided
|
||||||
* Ability to set reminder alarms (which will be flashed as the reminder)
|
* Ability to set reminder alarms (which will be flashed as the reminder or can be sent as webhooks/email notifications)
|
||||||
* Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports
|
* Ability to log time per day for different projects/activities, pomodoro-style log timer, timesheet reports and invoicing of time spent
|
||||||
* Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
|
* Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database.
|
||||||
|
* Add and manage tags on pages and documents
|
||||||
|
|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
@ -92,7 +100,6 @@ sudo apt update
|
||||||
sudo apt install bouquin
|
sudo apt install bouquin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### From PyPi/pip
|
### From PyPi/pip
|
||||||
|
|
||||||
* `pip install bouquin`
|
* `pip install bouquin`
|
||||||
|
|
@ -108,13 +115,4 @@ sudo apt install bouquin
|
||||||
* Run `poetry install` to install dependencies
|
* Run `poetry install` to install dependencies
|
||||||
* Run `poetry run bouquin` to start the application.
|
* Run `poetry run bouquin` to start the application.
|
||||||
|
|
||||||
### From the releases page
|
Alternatively, you can download the source code and wheels from Releases as well.
|
||||||
|
|
||||||
* Download the whl and run it
|
|
||||||
|
|
||||||
## How to run the tests
|
|
||||||
|
|
||||||
* Clone the repo
|
|
||||||
* Ensure you have poetry installed
|
|
||||||
* Run `poetry install --with test`
|
|
||||||
* Run `./tests.sh`
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from PySide6.QtCore import QRect, QSize, Qt
|
from PySide6.QtCore import QRect, QSize, Qt
|
||||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
|
@ -32,6 +34,12 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
# Allow Tab to insert indentation (not move focus between widgets)
|
||||||
|
self.setTabChangesFocus(False)
|
||||||
|
|
||||||
|
# Track whether we just auto-inserted indentation on Enter
|
||||||
|
self._last_enter_was_empty_indent = False
|
||||||
|
|
||||||
self._line_number_area = _LineNumberArea(self)
|
self._line_number_area = _LineNumberArea(self)
|
||||||
|
|
||||||
self.blockCountChanged.connect(self._update_line_number_area_width)
|
self.blockCountChanged.connect(self._update_line_number_area_width)
|
||||||
|
|
@ -140,6 +148,48 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
|
||||||
bottom = top + self.blockBoundingRect(block).height()
|
bottom = top + self.blockBoundingRect(block).height()
|
||||||
block_number += 1
|
block_number += 1
|
||||||
|
|
||||||
|
def keyPressEvent(self, event): # type: ignore[override]
|
||||||
|
"""Auto-retain indentation on newlines (Tab/space) like the markdown editor.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If the current line is indented, Enter inserts a newline + the same indent.
|
||||||
|
- If the current line contains only indentation, a *second* Enter clears the indent
|
||||||
|
and starts an unindented line (similar to exiting bullets/checkboxes).
|
||||||
|
"""
|
||||||
|
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
||||||
|
cursor = self.textCursor()
|
||||||
|
block_text = cursor.block().text()
|
||||||
|
indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
if indent:
|
||||||
|
rest = block_text[len(indent) :]
|
||||||
|
indent_only = rest.strip() == ""
|
||||||
|
|
||||||
|
if indent_only and self._last_enter_was_empty_indent:
|
||||||
|
# Second Enter on an indentation-only line: remove that line and
|
||||||
|
# start a fresh, unindented line.
|
||||||
|
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||||
|
cursor.removeSelectedText()
|
||||||
|
cursor.insertText("\n")
|
||||||
|
self.setTextCursor(cursor)
|
||||||
|
self._last_enter_was_empty_indent = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# First Enter: keep indentation
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
self.textCursor().insertText(indent)
|
||||||
|
self._last_enter_was_empty_indent = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# No indent -> normal Enter
|
||||||
|
self._last_enter_was_empty_indent = False
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Any other key resets the empty-indent-enter flag
|
||||||
|
self._last_enter_was_empty_indent = False
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
class CodeBlockEditorDialog(QDialog):
|
class CodeBlockEditorDialog(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@
|
||||||
"date_range": "Date range",
|
"date_range": "Date range",
|
||||||
"custom_range": "Custom",
|
"custom_range": "Custom",
|
||||||
"last_week": "Last week",
|
"last_week": "Last week",
|
||||||
|
"last_month": "Last month",
|
||||||
"this_week": "This week",
|
"this_week": "This week",
|
||||||
"this_month": "This month",
|
"this_month": "This month",
|
||||||
"this_year": "This year",
|
"this_year": "This year",
|
||||||
|
|
@ -302,6 +303,10 @@
|
||||||
"cut": "Cut",
|
"cut": "Cut",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
|
"remove_collapse": "Remove collapse",
|
||||||
|
"collapse_selection": "Collapse selection",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
|
|
@ -363,21 +368,19 @@
|
||||||
"documents_col_file": "File",
|
"documents_col_file": "File",
|
||||||
"documents_col_description": "Description",
|
"documents_col_description": "Description",
|
||||||
"documents_col_added": "Added",
|
"documents_col_added": "Added",
|
||||||
"documents_col_path": "Path",
|
|
||||||
"documents_col_tags": "Tags",
|
"documents_col_tags": "Tags",
|
||||||
"documents_col_size": "Size",
|
"documents_col_size": "Size",
|
||||||
"documents_add": "&Add",
|
"documents_add": "&Add",
|
||||||
"documents_add_document": "Add a document",
|
|
||||||
"documents_open": "&Open",
|
"documents_open": "&Open",
|
||||||
"documents_delete": "&Delete",
|
"documents_delete": "&Delete",
|
||||||
"documents_no_project_selected": "Please choose a project first.",
|
"documents_no_project_selected": "Please choose a project first.",
|
||||||
"documents_file_filter_all": "All files (*)",
|
"documents_file_filter_all": "All files (*)",
|
||||||
"documents_add_failed": "Could not add document: {error}",
|
"documents_add_failed": "Could not add document: {error}",
|
||||||
"documents_open_failed": "Could not open document: {error}",
|
"documents_open_failed": "Could not open document: {error}",
|
||||||
"documents_missing_file": "The file does not exist:\n{path}",
|
|
||||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||||
"documents_search_label": "Search",
|
"documents_search_label": "Search",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
|
"documents_invalid_date_format": "Invalid date format",
|
||||||
"todays_documents": "Documents from this day",
|
"todays_documents": "Documents from this day",
|
||||||
"todays_documents_none": "No documents yet.",
|
"todays_documents_none": "No documents yet.",
|
||||||
"manage_invoices": "Manage Invoices",
|
"manage_invoices": "Manage Invoices",
|
||||||
|
|
@ -428,5 +431,10 @@
|
||||||
"invoice_company_logo_choose": "Choose logo",
|
"invoice_company_logo_choose": "Choose logo",
|
||||||
"invoice_company_logo_set": "Logo has been set",
|
"invoice_company_logo_set": "Logo has been set",
|
||||||
"invoice_company_logo_not_set": "Logo not set",
|
"invoice_company_logo_not_set": "Logo not set",
|
||||||
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists."
|
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
|
||||||
|
"invoice_invalid_amount": "The amount is invalid",
|
||||||
|
"invoice_invalid_date_format": "Invalid date format",
|
||||||
|
"invoice_invalid_tax_rate": "The tax rate is invalid",
|
||||||
|
"invoice_no_items": "There are no items in the invoice",
|
||||||
|
"invoice_number_required": "An invoice number is required"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,290 +1,436 @@
|
||||||
{
|
{
|
||||||
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
|
"db_sqlcipher_integrity_check_failed": "Échec de la vérification d'intégrité SQLCipher",
|
||||||
"db_issues_reported": "problème(s) signalé(s)",
|
"db_issues_reported": "problème(s) signalé(s)",
|
||||||
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
|
"db_reopen_failed_after_rekey": "Échec de la réouverture après changement de clé",
|
||||||
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
|
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
|
||||||
"db_key_incorrect": "La clé est probablement incorrecte",
|
"db_key_incorrect": "La clé est probablement incorrecte",
|
||||||
"db_database_error": "Erreur de base de données",
|
"db_database_error": "Erreur de base de données",
|
||||||
"database_maintenance": "Maintenance de la base de données",
|
"database_maintenance": "Maintenance de la base de données",
|
||||||
"database_compact": "Compacter la base de données",
|
"database_compact": "Compacter la base de données",
|
||||||
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
|
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
|
||||||
"database_compacted_successfully": "Base de données compactée avec succès !",
|
"database_compacted_successfully": "Base de données compactée avec succès !",
|
||||||
"encryption": "Chiffrement",
|
"encryption": "Chiffrement",
|
||||||
"remember_key": "Se souvenir de la clé",
|
"remember_key": "Se souvenir de la clé",
|
||||||
"change_encryption_key": "Changer la clé de chiffrement",
|
"change_encryption_key": "Changer la clé de chiffrement",
|
||||||
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
|
"enter_a_new_encryption_key": "Saisir une nouvelle clé de chiffrement",
|
||||||
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
|
"reenter_the_new_key": "Saisir de nouveau la nouvelle clé",
|
||||||
"key_mismatch": "Les clés ne correspondent pas",
|
"key_mismatch": "Les clés ne correspondent pas",
|
||||||
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
|
"key_mismatch_explanation": "Les deux saisies ne correspondent pas.",
|
||||||
"empty_key": "La clé est vide",
|
"empty_key": "La clé est vide",
|
||||||
"empty_key_explanation": "La clé ne peut pas être vide.",
|
"empty_key_explanation": "La clé ne peut pas être vide.",
|
||||||
"key_changed": "La clé a été modifiée",
|
"key_changed": "La clé a été modifiée",
|
||||||
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
|
"key_changed_explanation": "Le bouquin a été rechiffré avec la nouvelle clé !",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"find": "Rechercher",
|
"find": "Rechercher",
|
||||||
"file": "Fichier",
|
"file": "Fichier",
|
||||||
"locale": "Langue",
|
"locale": "Langue",
|
||||||
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
|
"locale_restart": "Veuillez redémarrer l'application pour appliquer la nouvelle langue.",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"theme": "Thème",
|
"theme": "Thème",
|
||||||
"system": "Système",
|
"system": "Système",
|
||||||
"light": "Clair",
|
"light": "Clair",
|
||||||
"dark": "Sombre",
|
"dark": "Sombre",
|
||||||
"never": "Jamais",
|
"never": "Jamais",
|
||||||
"close_tab": "Fermer l'onglet",
|
"close_tab": "Fermer l'onglet",
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"previous_day": "Jour précédent",
|
"previous_day": "Jour précédent",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"next_day": "Jour suivant",
|
"next_day": "Jour suivant",
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
"show": "Afficher",
|
"show": "Afficher",
|
||||||
"history": "Historique",
|
"edit": "Modifier",
|
||||||
"export_accessible_flag": "E&xporter",
|
"delete": "Supprimer",
|
||||||
"export_entries": "Exporter les entrées",
|
"history": "Historique",
|
||||||
"export_complete": "Exportation terminée",
|
"export_accessible_flag": "E&xporter",
|
||||||
"export_failed": "Échec de l'exportation",
|
"export_entries": "Exporter les entrées",
|
||||||
"backup": "Sauvegarder",
|
"export_complete": "Exportation terminée",
|
||||||
"backup_complete": "Sauvegarde terminée",
|
"export_failed": "Échec de l'exportation",
|
||||||
"backup_failed": "Échec de la sauvegarde",
|
"backup": "Sauvegarder",
|
||||||
"quit": "Quitter",
|
"backup_complete": "Sauvegarde terminée",
|
||||||
"cancel": "Annuler",
|
"backup_failed": "Échec de la sauvegarde",
|
||||||
"save": "Enregistrer",
|
"quit": "Quitter",
|
||||||
"help": "Aide",
|
"cancel": "Annuler",
|
||||||
"saved": "Enregistré",
|
"save": "Enregistrer",
|
||||||
"saved_to": "Enregistré dans",
|
"help": "Aide",
|
||||||
"documentation": "Documentation",
|
"saved": "Enregistré",
|
||||||
"couldnt_open": "Impossible d'ouvrir",
|
"saved_to": "Enregistré dans",
|
||||||
"report_a_bug": "Signaler un bug",
|
"documentation": "Documentation",
|
||||||
"version": "Version",
|
"couldnt_open": "Impossible d'ouvrir",
|
||||||
"update": "Mise à jour",
|
"report_a_bug": "Signaler un bug",
|
||||||
"check_for_updates": "Rechercher des mises à jour",
|
"version": "Version",
|
||||||
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
|
"update": "Mise à jour",
|
||||||
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
|
"check_for_updates": "Rechercher des mises à jour",
|
||||||
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
|
"could_not_check_for_updates": "Impossible de vérifier les mises à jour:\n",
|
||||||
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
|
"update_server_returned_an_empty_version_string": "Le serveur de mise à jour a renvoyé une chaîne de version vide",
|
||||||
"download_the_appimage": "Télécharger l'AppImage ?",
|
"you_are_running_the_latest_version": "Vous utilisez déjà la dernière version:\n",
|
||||||
"downloading": "Téléchargement en cours",
|
"there_is_a_new_version_available": "Une nouvelle version est disponible:\n",
|
||||||
"download_cancelled": "Téléchargement annulé",
|
"download_the_appimage": "Télécharger l'AppImage ?",
|
||||||
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
|
"downloading": "Téléchargement en cours",
|
||||||
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
|
"download_cancelled": "Téléchargement annulé",
|
||||||
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
|
"failed_to_download_update": "Échec du téléchargement de la mise à jour:\n",
|
||||||
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
|
"could_not_read_bundled_gpg_public_key": "Impossible de lire la clé publique GPG fournie:\n",
|
||||||
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
|
"could_not_find_gpg_executable": "Impossible de trouver l'exécutable 'gpg' pour vérifier le téléchargement.",
|
||||||
"navigate": "Naviguer",
|
"gpg_signature_verification_failed": "Échec de la vérification de la signature GPG. Les fichiers téléchargés ont été supprimés.\n\n",
|
||||||
"current": "actuel",
|
"downloaded_and_verified_new_appimage": "Nouvelle AppImage téléchargée et vérifiée:\n\n",
|
||||||
"selected": "sélectionné",
|
"navigate": "Naviguer",
|
||||||
"find_on_page": "Rechercher dans la page",
|
"current": "actuel",
|
||||||
"find_next": "Rechercher le suivant",
|
"selected": "sélectionné",
|
||||||
"find_previous": "Rechercher le précédent",
|
"find_on_page": "Rechercher dans la page",
|
||||||
"find_bar_type_to_search": "Tapez pour rechercher",
|
"find_next": "Rechercher le suivant",
|
||||||
"find_bar_match_case": "Respecter la casse",
|
"find_previous": "Rechercher le précédent",
|
||||||
"history_dialog_preview": "Aperçu",
|
"find_bar_type_to_search": "Tapez pour rechercher",
|
||||||
"history_dialog_diff": "Différences",
|
"find_bar_match_case": "Respecter la casse",
|
||||||
"history_dialog_revert_to_selected": "Revenir à la sélection",
|
"history_dialog_preview": "Aperçu",
|
||||||
"history_dialog_revert_failed": "Échec de la restauration",
|
"history_dialog_diff": "Différences",
|
||||||
"history_dialog_delete": "Supprimer la révision",
|
"history_dialog_revert_to_selected": "Revenir à la sélection",
|
||||||
"history_dialog_delete_failed": "Impossible de supprimer la révision",
|
"history_dialog_revert_failed": "Échec de la restauration",
|
||||||
"key_prompt_enter_key": "Saisir la clé",
|
"history_dialog_delete": "Supprimer la révision",
|
||||||
"lock_overlay_locked": "Verrouillé",
|
"history_dialog_delete_failed": "Impossible de supprimer la révision",
|
||||||
"lock_overlay_unlock": "Déverrouiller",
|
"key_prompt_enter_key": "Saisir la clé",
|
||||||
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
|
"lock_overlay_locked": "Verrouillé",
|
||||||
"main_window_ready": "Prêt",
|
"lock_overlay_unlock": "Déverrouiller",
|
||||||
"main_window_save_a_version": "Enregistrer une version",
|
"main_window_lock_screen_accessibility": "&Verrouiller l'écran",
|
||||||
"main_window_settings_accessible_flag": "&Paramètres",
|
"main_window_ready": "Prêt",
|
||||||
"set_an_encryption_key": "Définir une clé de chiffrement",
|
"main_window_save_a_version": "Enregistrer une version",
|
||||||
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
|
"main_window_settings_accessible_flag": "&Paramètres",
|
||||||
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
|
"set_an_encryption_key": "Définir une clé de chiffrement",
|
||||||
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
|
"set_an_encryption_key_explanation": "Bouquin chiffre vos données.\n\nVeuillez créer une phrase de passe robuste pour chiffrer le bouquin.\n\nVous pourrez toujours la modifier plus tard !",
|
||||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
"unlock_encrypted_notebook": "Déverrouiller le bouquin chiffré",
|
||||||
"autosave": "enregistrement automatique",
|
"unlock_encrypted_notebook_explanation": "Saisir votre clé pour déverrouiller le bouquin",
|
||||||
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
|
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||||
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
|
"autosave": "enregistrement automatique",
|
||||||
"insert_images": "Insérer des images",
|
"unchecked_checkbox_items_moved_to_next_day": "Les cases non cochées ont été reportées au jour suivant",
|
||||||
"images": "Images",
|
"move_unchecked_todos_to_today_on_startup": "Déplacer automatiquement les TODO non cochés\ndes 7 derniers jours vers le prochain jour ouvrable",
|
||||||
"reopen_failed": "Échec de la réouverture",
|
"move_todos_include_weekends": "Autoriser le déplacement des TODO non cochées vers un week-end\nplutôt que vers le prochain jour ouvrable",
|
||||||
"unlock_failed": "Échec du déverrouillage",
|
"insert_images": "Insérer des images",
|
||||||
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
|
"images": "Images",
|
||||||
"unencrypted_export": "Export non chiffré",
|
"reopen_failed": "Échec de la réouverture",
|
||||||
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
|
"unlock_failed": "Échec du déverrouillage",
|
||||||
"unrecognised_extension": "Extension non reconnue !",
|
"could_not_unlock_database_at_new_path": "Impossible de déverrouiller la base de données au nouveau chemin.",
|
||||||
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
|
"unencrypted_export": "Export non chiffré",
|
||||||
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
|
"unencrypted_export_warning": "L'exportation de la base de données ne sera pas chiffrée !\nÊtes-vous sûr de vouloir continuer ?\nSi vous voulez une sauvegarde chiffrée, choisissez Sauvegarde plutôt qu'Export.",
|
||||||
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
|
"unrecognised_extension": "Extension non reconnue !",
|
||||||
"appearance": "Apparence",
|
"backup_encrypted_notebook": "Sauvegarder le bouquin chiffré",
|
||||||
"security": "Sécurité",
|
"enter_a_name_for_this_version": "Saisir un nom pour cette version",
|
||||||
"features": "Fonctionnalités",
|
"new_version_i_saved_at": "Nouvelle version que j'ai enregistrée à",
|
||||||
"database": "Base de données",
|
"appearance": "Apparence",
|
||||||
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
|
"security": "Sécurité",
|
||||||
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
|
"features": "Fonctionnalités",
|
||||||
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
|
"database": "Base de données",
|
||||||
"font_size": "Taille de police",
|
"save_key_warning": "Si vous ne voulez pas que l'on vous demande votre clé de chiffrement, cochez cette case pour la mémoriser.\nAVERTISSEMENT : la clé est enregistrée sur le disque et pourrait être récupérée si votre disque est compromis.",
|
||||||
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
|
"lock_screen_when_idle": "Verrouiller l'écran en cas d'inactivité",
|
||||||
"search_for_notes_here": "Recherchez des notes ici",
|
"autolock_explanation": "Bouquin verrouillera automatiquement le bouquin après cette durée, après quoi vous devrez ressaisir la clé pour le déverrouiller.\nMettre à 0 (jamais) pour ne jamais verrouiller.",
|
||||||
"toolbar_format": "Format",
|
"font_size": "Taille de police",
|
||||||
"toolbar_bold": "Gras",
|
"font_size_explanation": "La modification de cette valeur change la taille de tout le texte de paragraphe dans tous les onglets. Cela n'affecte pas la taille des titres ni des blocs de code.",
|
||||||
"toolbar_italic": "Italique",
|
"search_for_notes_here": "Recherchez des notes ici",
|
||||||
"toolbar_strikethrough": "Barré",
|
"toolbar_format": "Format",
|
||||||
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
|
"toolbar_bold": "Gras",
|
||||||
"toolbar_font_smaller": "Texte plus petit",
|
"toolbar_italic": "Italique",
|
||||||
"toolbar_font_larger": "Texte plus grand",
|
"toolbar_strikethrough": "Barré",
|
||||||
"toolbar_bulleted_list": "Liste à puces",
|
"toolbar_normal_paragraph_text": "Texte de paragraphe normal",
|
||||||
"toolbar_numbered_list": "Liste numérotée",
|
"toolbar_font_smaller": "Texte plus petit",
|
||||||
"toolbar_code_block": "Bloc de code",
|
"toolbar_font_larger": "Texte plus grand",
|
||||||
"toolbar_heading": "Titre",
|
"toolbar_bulleted_list": "Liste à puces",
|
||||||
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
|
"toolbar_numbered_list": "Liste numérotée",
|
||||||
"tags": "Étiquettes",
|
"toolbar_code_block": "Bloc de code",
|
||||||
"tag": "Étiquette",
|
"toolbar_heading": "Titre",
|
||||||
"manage_tags": "Gérer les étiquettes",
|
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
|
||||||
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
|
"tags": "Étiquettes",
|
||||||
"tag_browser_title": "Navigateur d'étiquettes",
|
"tag": "Étiquette",
|
||||||
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
|
"manage_tags": "Gérer les étiquettes",
|
||||||
"color_hex": "Couleur",
|
"add_tag_placeholder": "Ajouter une étiquette puis appuyez sur Entrée",
|
||||||
"date": "Date",
|
"tag_browser_title": "Navigateur d'étiquettes",
|
||||||
"add_a_tag": "Ajouter une étiquette",
|
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
|
||||||
"edit_tag_name": "Modifier le nom de l'étiquette",
|
"color_hex": "Couleur",
|
||||||
"new_tag_name": "Nouveau nom de l'étiquette :",
|
"date": "Date",
|
||||||
"change_color": "Changer la couleur",
|
"page_or_document": "Page / Document",
|
||||||
"delete_tag": "Supprimer l'étiquette",
|
"add_a_tag": "Ajouter une étiquette",
|
||||||
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
|
"edit_tag_name": "Modifier le nom de l'étiquette",
|
||||||
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
|
"new_tag_name": "Nouveau nom de l'étiquette :",
|
||||||
"statistics": "Statistiques",
|
"change_color": "Changer la couleur",
|
||||||
"main_window_statistics_accessible_flag": "Stat&istiques",
|
"delete_tag": "Supprimer l'étiquette",
|
||||||
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
|
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
|
||||||
"stats_total_revisions": "Nombre total de révisions",
|
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
|
||||||
"stats_page_most_revisions": "Page avec le plus de révisions",
|
"statistics": "Statistiques",
|
||||||
"stats_total_words": "Nombre total de mots (versions actuelles)",
|
"main_window_statistics_accessible_flag": "Stat&istiques",
|
||||||
"stats_unique_tags": "Étiquettes uniques",
|
"stats_group_pages": "Pages",
|
||||||
"stats_page_most_tags": "Page avec le plus d'étiquettes",
|
"stats_group_tags": "Étiquettes",
|
||||||
"stats_activity_heatmap": "Carte de chaleur d'activité",
|
"stats_group_documents": "Documents",
|
||||||
"stats_heatmap_metric": "Colorer selon",
|
"stats_group_time_logging": "Journal de temps",
|
||||||
"stats_metric_words": "Mots",
|
"stats_group_reminders": "Rappels",
|
||||||
"stats_metric_revisions": "Révisions",
|
"stats_pages_with_content": "Pages avec contenu (version actuelle)",
|
||||||
"stats_no_data": "Aucune statistique disponible pour le moment.",
|
"stats_total_revisions": "Nombre total de révisions",
|
||||||
"select_notebook": "Sélectionner un bouquin",
|
"stats_page_most_revisions": "Page avec le plus de révisions",
|
||||||
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
|
"stats_total_words": "Nombre total de mots (versions actuelles)",
|
||||||
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
|
"stats_unique_tags": "Étiquettes uniques",
|
||||||
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
|
"stats_page_most_tags": "Page avec le plus d'étiquettes",
|
||||||
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
|
"stats_activity_heatmap": "Carte de chaleur d'activité",
|
||||||
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
|
"stats_heatmap_metric": "Colorer selon",
|
||||||
"send": "Envoyer",
|
"stats_metric_words": "Mots",
|
||||||
"reminder": "Rappel",
|
"stats_metric_revisions": "Révisions",
|
||||||
"set_reminder": "Définir le rappel",
|
"stats_metric_documents": "Documents",
|
||||||
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
|
"stats_total_documents": "Total des documents",
|
||||||
"invalid_time_title": "Heure invalide",
|
"stats_date_most_documents": "Date avec le plus de documents",
|
||||||
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
|
"stats_no_data": "Aucune statistique disponible pour le moment.",
|
||||||
"dismiss": "Ignorer",
|
"stats_time_total_hours": "Total des heures enregistrées",
|
||||||
"toolbar_alarm": "Régler l'alarme de rappel",
|
"stats_time_day_most_hours": "Jour avec le plus d'heures enregistrées",
|
||||||
"activities": "Activités",
|
"stats_time_project_most_hours": "Projet avec le plus d'heures enregistrées",
|
||||||
"activity": "Activité",
|
"stats_time_activity_most_hours": "Activité avec le plus d'heures enregistrées",
|
||||||
"note": "Note",
|
"stats_total_reminders": "Total des rappels",
|
||||||
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
|
"stats_date_most_reminders": "Jour avec le plus de rappels",
|
||||||
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
|
"stats_metric_hours": "Heures",
|
||||||
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
|
"stats_metric_reminders": "Rappels",
|
||||||
"activity_rename_error_title": "Problème lors du renommage de l'activité",
|
"select_notebook": "Sélectionner un bouquin",
|
||||||
"activity_required_message": "Un nom d'activité est requis",
|
"bug_report_explanation": "Décrivez ce qui s'est mal passé, ce que vous attendiez et les étapes pour reproduire le problème.\n\nNous ne collectons rien d'autre que le numéro de version de Bouquin.\n\nSi vous souhaitez être contacté, veuillez laisser vos coordonnées.\n\nVotre demande sera envoyée via HTTPS.",
|
||||||
"activity_required_title": "Nom d'activité requis",
|
"bug_report_placeholder": "Saisissez votre rapport de bug ici",
|
||||||
"add_activity": "Ajouter une activité",
|
"bug_report_empty": "Veuillez saisir quelques détails sur le bug avant l'envoi.",
|
||||||
"add_project": "Ajouter un projet",
|
"bug_report_send_failed": "Impossible d'envoyer le rapport de bug.",
|
||||||
"add_time_entry": "Ajouter une entrée de temps",
|
"bug_report_sent_ok": "Rapport de bug envoyé. Merci !",
|
||||||
"time_period": "Période",
|
"send": "Envoyer",
|
||||||
"by_day": "par jour",
|
"reminder": "Rappel",
|
||||||
"by_month": "par mois",
|
"set_reminder": "Définir le rappel",
|
||||||
"by_week": "par semaine",
|
"reminder_no_text_fallback": "Vous avez programmé un rappel pour maintenant !",
|
||||||
"date_range": "Plage de dates",
|
"invalid_time_title": "Heure invalide",
|
||||||
"delete_activity": "Supprimer l'activité",
|
"invalid_time_message": "Veuillez saisir une heure au format HH:MM",
|
||||||
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
|
"dismiss": "Ignorer",
|
||||||
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
|
"toolbar_alarm": "Régler l'alarme de rappel",
|
||||||
"delete_project": "Supprimer le projet",
|
"activities": "Activités",
|
||||||
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
|
"activity": "Activité",
|
||||||
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
|
"note": "Note",
|
||||||
"delete_time_entry": "Supprimer l'entrée de temps",
|
"activity_delete_error_message": "Un problème est survenu lors de la suppression de l'activité",
|
||||||
"group_by": "Grouper par",
|
"activity_delete_error_title": "Problème lors de la suppression de l'activité",
|
||||||
"hours": "Heures",
|
"activity_rename_error_message": "Un problème est survenu lors du renommage de l'activité",
|
||||||
"invalid_activity_message": "L'activité est invalide",
|
"activity_rename_error_title": "Problème lors du renommage de l'activité",
|
||||||
"invalid_activity_title": "Activité invalide",
|
"activity_required_message": "Un nom d'activité est requis",
|
||||||
"invalid_project_message": "Le projet est invalide",
|
"activity_required_title": "Nom d'activité requis",
|
||||||
"invalid_project_title": "Projet invalide",
|
"add_activity": "Ajouter une activité",
|
||||||
"manage_activities": "Gérer les activités",
|
"add_project": "Ajouter un projet",
|
||||||
"manage_projects": "Gérer les projets",
|
"add_time_entry": "Ajouter une entrée de temps",
|
||||||
"manage_projects_activities": "Gérer les activités du projet",
|
"time_period": "Période",
|
||||||
"open_time_log": "Ouvrir le journal de temps",
|
"dont_group": "Ne pas regrouper",
|
||||||
"project": "Projet",
|
"by_activity": "par activité",
|
||||||
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
|
"by_day": "par jour",
|
||||||
"project_delete_error_title": "Problème lors de la suppression du projet",
|
"by_month": "par mois",
|
||||||
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
|
"by_week": "par semaine",
|
||||||
"project_rename_error_title": "Problème lors du renommage du projet",
|
"date_range": "Plage de dates",
|
||||||
"project_required_message": "Un projet est requis",
|
"custom_range": "Personnalisé",
|
||||||
"project_required_title": "Projet requis",
|
"last_week": "La semaine dernière",
|
||||||
"projects": "Projets",
|
"last_month": "Le mois dernier",
|
||||||
"rename_activity": "Renommer l'activité",
|
"this_week": "Cette semaine",
|
||||||
"rename_project": "Renommer le projet",
|
"this_month": "Ce mois-ci",
|
||||||
"run_report": "Exécuter le rapport",
|
"this_year": "Cette année",
|
||||||
"add_activity_title": "Ajouter une activité",
|
"all_projects": "Tous les projets",
|
||||||
"add_activity_label": "Ajouter une activité",
|
"delete_activity": "Supprimer l'activité",
|
||||||
"rename_activity_label": "Renommer l'activité",
|
"delete_activity_confirm": "Êtes-vous sûr de vouloir supprimer cette activité ?",
|
||||||
"add_project_title": "Ajouter un projet",
|
"delete_activity_title": "Supprimer l'activité - êtes-vous sûr ?",
|
||||||
"add_project_label": "Ajouter un projet",
|
"delete_project": "Supprimer le projet",
|
||||||
"rename_activity_title": "Renommer cette activité",
|
"delete_project_confirm": "Êtes-vous sûr de vouloir supprimer ce projet ?",
|
||||||
"rename_project_label": "Renommer le projet",
|
"delete_project_title": "Supprimer le projet - êtes-vous sûr ?",
|
||||||
"rename_project_title": "Renommer ce projet",
|
"delete_time_entry": "Supprimer l'entrée de temps",
|
||||||
"select_activity_message": "Sélectionner une activité",
|
"group_by": "Grouper par",
|
||||||
"select_activity_title": "Sélectionner une activité",
|
"hours": "Heures",
|
||||||
"select_project_message": "Sélectionner un projet",
|
"created_at": "Créé le",
|
||||||
"select_project_title": "Sélectionner un projet",
|
"invalid_activity_message": "L'activité est invalide",
|
||||||
"time_log": "Journal de temps",
|
"invalid_activity_title": "Activité invalide",
|
||||||
"time_log_collapsed_hint": "Journal de temps",
|
"invalid_project_message": "Le projet est invalide",
|
||||||
"time_log_date_label": "Date du journal de temps : {date}",
|
"invalid_project_title": "Projet invalide",
|
||||||
"time_log_for": "Journal de temps pour {date}",
|
"manage_activities": "Gérer les activités",
|
||||||
"time_log_no_date": "Journal de temps",
|
"manage_projects": "Gérer les projets",
|
||||||
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
|
"manage_projects_activities": "Gérer les activités du projet",
|
||||||
"time_log_report": "Rapport de temps",
|
"open_time_log": "Ouvrir le journal de temps",
|
||||||
"time_log_report_title": "Journal de temps pour {project}",
|
"project": "Projet",
|
||||||
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
|
"project_delete_error_message": "Un problème est survenu lors de la suppression du projet",
|
||||||
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
|
"project_delete_error_title": "Problème lors de la suppression du projet",
|
||||||
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
|
"project_rename_error_message": "Un problème est survenu lors du renommage du projet",
|
||||||
"update_time_entry": "Mettre à jour l'entrée de temps",
|
"project_rename_error_title": "Problème lors du renommage du projet",
|
||||||
"time_report_total": "Total : {hours:.2f} heures",
|
"project_required_message": "Un projet est requis",
|
||||||
"no_report_title": "Aucun rapport",
|
"project_required_title": "Projet requis",
|
||||||
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
|
"projects": "Projets",
|
||||||
"total": "Total",
|
"rename_activity": "Renommer l'activité",
|
||||||
"export_csv": "Exporter en CSV",
|
"rename_project": "Renommer le projet",
|
||||||
"export_csv_error_title": "Échec de l'exportation",
|
"reporting": "Rapports",
|
||||||
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
|
"reporting_and_invoicing": "Rapports et facturation",
|
||||||
"export_pdf": "Exporter en PDF",
|
"run_report": "Exécuter le rapport",
|
||||||
"export_pdf_error_title": "Échec de l'exportation PDF",
|
"add_activity_title": "Ajouter une activité",
|
||||||
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
|
"add_activity_label": "Ajouter une activité",
|
||||||
"enable_tags_feature": "Activer les étiquettes",
|
"rename_activity_label": "Renommer l'activité",
|
||||||
"enable_time_log_feature": "Activer le journal de temps",
|
"add_project_title": "Ajouter un projet",
|
||||||
"enable_reminders_feature": "Activer les rappels",
|
"add_project_label": "Ajouter un projet",
|
||||||
"pomodoro_time_log_default_text": "Session de concentration",
|
"rename_activity_title": "Renommer cette activité",
|
||||||
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
|
"rename_project_label": "Renommer le projet",
|
||||||
"set_code_language": "Définir le langage du code",
|
"rename_project_title": "Renommer ce projet",
|
||||||
"cut": "Couper",
|
"select_activity_message": "Sélectionner une activité",
|
||||||
"copy": "Copier",
|
"select_activity_title": "Sélectionner une activité",
|
||||||
"paste": "Coller",
|
"select_project_message": "Sélectionner un projet",
|
||||||
"start": "Démarrer",
|
"select_project_title": "Sélectionner un projet",
|
||||||
"pause": "Pause",
|
"time_log": "Journal de temps",
|
||||||
"resume": "Reprendre",
|
"time_log_collapsed_hint": "Journal de temps",
|
||||||
"stop_and_log": "Arrêter et enregistrer",
|
"date_label": "Date : {date}",
|
||||||
"once": "une fois",
|
"change_date": "Modifier la date",
|
||||||
"daily": "quotidien",
|
"select_date_title": "Sélectionner une date",
|
||||||
"weekdays": "jours de semaine",
|
"for": "Pour {date}",
|
||||||
"weekly": "hebdomadaire",
|
"time_log_no_date": "Journal de temps",
|
||||||
"edit_reminder": "Modifier le rappel",
|
"time_log_no_entries": "Aucune entrée de temps pour l'instant",
|
||||||
"time": "Heure",
|
"time_log_report": "Rapport de temps",
|
||||||
"once": "Une fois (aujourd'hui)",
|
"time_log_report_title": "Journal de temps pour {project}",
|
||||||
"every_day": "Tous les jours",
|
"time_log_report_meta": "Du {start} au {end}, groupé par {granularity}",
|
||||||
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
"time_log_total_hours": "Total pour la journée : {hours:.2f}h",
|
||||||
"every_week": "Toutes les semaines",
|
"time_log_with_total": "Journal de temps ({hours:.2f}h)",
|
||||||
"repeat": "Répéter",
|
"update_time_entry": "Mettre à jour l'entrée de temps",
|
||||||
"monday": "Lundi",
|
"time_report_total": "Total : {hours:.2f} heures",
|
||||||
"tuesday": "Mardi",
|
"no_report_title": "Aucun rapport",
|
||||||
"wednesday": "Mercredi",
|
"no_report_message": "Veuillez exécuter un rapport avant d'exporter.",
|
||||||
"thursday": "Jeudi",
|
"total": "Total",
|
||||||
"friday": "Vendredi",
|
"export_csv": "Exporter en CSV",
|
||||||
"saturday": "Samedi",
|
"export_csv_error_title": "Échec de l'exportation",
|
||||||
"sunday": "Dimanche",
|
"export_csv_error_message": "Impossible d'écrire le fichier CSV:\n{error}",
|
||||||
"day": "Jour"
|
"export_pdf": "Exporter en PDF",
|
||||||
|
"export_pdf_error_title": "Échec de l'exportation PDF",
|
||||||
|
"export_pdf_error_message": "Impossible d'écrire le fichier PDF:\n{error}",
|
||||||
|
"enable_tags_feature": "Activer les étiquettes",
|
||||||
|
"enable_time_log_feature": "Activer le journal de temps",
|
||||||
|
"enable_reminders_feature": "Activer les rappels",
|
||||||
|
"reminders_webhook_section_title": "Envoyer les rappels vers un webhook",
|
||||||
|
"reminders_webhook_url_label": "URL du webhook",
|
||||||
|
"reminders_webhook_secret_label": "Secret du webhook (envoyé dans l'en-tête\nX-Bouquin-Secret)",
|
||||||
|
"enable_documents_feature": "Activer le stockage des documents",
|
||||||
|
"pomodoro_time_log_default_text": "Session de concentration",
|
||||||
|
"toolbar_pomodoro_timer": "Minuteur de suivi du temps",
|
||||||
|
"set_code_language": "Définir le langage du code",
|
||||||
|
"cut": "Couper",
|
||||||
|
"copy": "Copier",
|
||||||
|
"paste": "Coller",
|
||||||
|
"collapse": "Replier",
|
||||||
|
"expand": "Déplier",
|
||||||
|
"remove_collapse": "Supprimer le pliage",
|
||||||
|
"collapse_selection": "Replier la sélection",
|
||||||
|
"start": "Démarrer",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Reprendre",
|
||||||
|
"stop_and_log": "Arrêter et enregistrer",
|
||||||
|
"manage_reminders": "Gérer les rappels",
|
||||||
|
"upcoming_reminders": "Rappels à venir",
|
||||||
|
"no_upcoming_reminders": "Aucun rappel à venir",
|
||||||
|
"once": "Une fois (aujourd'hui)",
|
||||||
|
"daily": "quotidien",
|
||||||
|
"weekdays": "jours de semaine",
|
||||||
|
"weekly": "hebdomadaire",
|
||||||
|
"add_reminder": "Ajouter un rappel",
|
||||||
|
"edit_reminder": "Modifier le rappel",
|
||||||
|
"delete_reminder": "Supprimer le rappel",
|
||||||
|
"delete_reminders": "Supprimer les rappels",
|
||||||
|
"deleting_it_will_remove_all_future_occurrences": "La suppression supprimera toutes les occurrences futures.",
|
||||||
|
"this_is_a_reminder_of_type": "Note : il s'agit d'un rappel de type",
|
||||||
|
"this_will_delete_the_actual_reminders": "Note : cela supprimera les rappels eux-mêmes, pas seulement des occurrences individuelles.",
|
||||||
|
"reminders": "Rappels",
|
||||||
|
"time": "Heure",
|
||||||
|
"every_day": "Tous les jours",
|
||||||
|
"every_weekday": "Tous les jours de semaine (lun-ven)",
|
||||||
|
"every_week": "Toutes les semaines",
|
||||||
|
"every_fortnight": "Toutes les 2 semaines",
|
||||||
|
"every_month": "Chaque mois (même date)",
|
||||||
|
"every_month_nth_weekday": "Chaque mois (ex. 3e lundi)",
|
||||||
|
"week_in_month": "Semaine du mois",
|
||||||
|
"fortnightly": "Toutes les deux semaines",
|
||||||
|
"monthly_same_date": "Mensuel (même date)",
|
||||||
|
"monthly_nth_weekday": "Mensuel (nᵉ jour de semaine)",
|
||||||
|
"repeat": "Répéter",
|
||||||
|
"monday": "Lundi",
|
||||||
|
"tuesday": "Mardi",
|
||||||
|
"wednesday": "Mercredi",
|
||||||
|
"thursday": "Jeudi",
|
||||||
|
"friday": "Vendredi",
|
||||||
|
"saturday": "Samedi",
|
||||||
|
"sunday": "Dimanche",
|
||||||
|
"monday_short": "Lun",
|
||||||
|
"tuesday_short": "Mar",
|
||||||
|
"wednesday_short": "Mer",
|
||||||
|
"thursday_short": "Jeu",
|
||||||
|
"friday_short": "Ven",
|
||||||
|
"saturday_short": "Sam",
|
||||||
|
"sunday_short": "Dim",
|
||||||
|
"day": "Jour",
|
||||||
|
"text": "Texte",
|
||||||
|
"type": "Type",
|
||||||
|
"active": "Actif",
|
||||||
|
"actions": "Actions",
|
||||||
|
"edit_code_block": "Modifier le bloc de code",
|
||||||
|
"delete_code_block": "Supprimer le bloc de code",
|
||||||
|
"search_result_heading_document": "Document",
|
||||||
|
"toolbar_documents": "Gestionnaire de documents",
|
||||||
|
"project_documents_title": "Documents du projet",
|
||||||
|
"documents_col_file": "Fichier",
|
||||||
|
"documents_col_description": "Description",
|
||||||
|
"documents_col_added": "Ajouté",
|
||||||
|
"documents_col_tags": "Étiquettes",
|
||||||
|
"documents_col_size": "Taille",
|
||||||
|
"documents_add": "&Ajouter",
|
||||||
|
"documents_open": "&Ouvrir",
|
||||||
|
"documents_delete": "&Supprimer",
|
||||||
|
"documents_no_project_selected": "Veuillez d'abord choisir un projet.",
|
||||||
|
"documents_file_filter_all": "Tous les fichiers (*)",
|
||||||
|
"documents_add_failed": "Impossible d'ajouter le document : {error}",
|
||||||
|
"documents_open_failed": "Impossible d'ouvrir le document : {error}",
|
||||||
|
"documents_confirm_delete": "Retirer ce document du projet ?\n(Le fichier sur le disque ne sera pas supprimé.)",
|
||||||
|
"documents_search_label": "Rechercher",
|
||||||
|
"documents_search_placeholder": "Saisir pour rechercher des documents (tous les projets)",
|
||||||
|
"documents_invalid_date_format": "Format de date invalide",
|
||||||
|
"todays_documents": "Documents de ce jour",
|
||||||
|
"todays_documents_none": "Aucun document pour le moment.",
|
||||||
|
"manage_invoices": "Gérer les factures",
|
||||||
|
"create_invoice": "Créer une facture",
|
||||||
|
"invoice_amount": "Montant",
|
||||||
|
"invoice_apply_tax": "Appliquer la taxe",
|
||||||
|
"invoice_client_address": "Adresse du client",
|
||||||
|
"invoice_client_company": "Société cliente",
|
||||||
|
"invoice_client_email": "E-mail du client",
|
||||||
|
"invoice_client_name": "Contact client",
|
||||||
|
"invoice_currency": "Devise",
|
||||||
|
"invoice_dialog_title": "Créer une facture",
|
||||||
|
"invoice_due_date": "Date d'échéance",
|
||||||
|
"invoice_hourly_rate": "Taux horaire",
|
||||||
|
"invoice_hours": "Heures",
|
||||||
|
"invoice_issue_date": "Date d'émission",
|
||||||
|
"invoice_mode_detailed": "Mode détaillé",
|
||||||
|
"invoice_mode_summary": "Mode récapitulatif",
|
||||||
|
"invoice_number": "Numéro de facture",
|
||||||
|
"invoice_save_and_export": "Enregistrer et exporter",
|
||||||
|
"invoice_save_pdf_title": "Enregistrer le PDF",
|
||||||
|
"invoice_subtotal": "Sous-total",
|
||||||
|
"invoice_summary_default_desc": "Services de conseil pour le mois de",
|
||||||
|
"invoice_summary_desc": "Description du récapitulatif",
|
||||||
|
"invoice_summary_hours": "Heures du récapitulatif",
|
||||||
|
"invoice_tax": "Détails de la taxe",
|
||||||
|
"invoice_tax_label": "Type de taxe",
|
||||||
|
"invoice_tax_rate": "Taux de taxe",
|
||||||
|
"invoice_tax_total": "Total des taxes",
|
||||||
|
"invoice_total": "Total",
|
||||||
|
"invoice_paid_at": "Payée le",
|
||||||
|
"invoice_payment_note": "Notes de paiement",
|
||||||
|
"invoice_project_required_title": "Projet requis",
|
||||||
|
"invoice_project_required_message": "Veuillez sélectionner un projet spécifique avant d'essayer de créer une facture.",
|
||||||
|
"invoice_need_report_title": "Rapport requis",
|
||||||
|
"invoice_need_report_message": "Veuillez exécuter un rapport de temps avant d'essayer de créer une facture à partir de celui-ci.",
|
||||||
|
"invoice_due_before_issue": "La date d'échéance ne peut pas être antérieure à la date d'émission.",
|
||||||
|
"invoice_paid_before_issue": "La date de paiement ne peut pas être antérieure à la date d'émission.",
|
||||||
|
"enable_invoicing_feature": "Activer la facturation (nécessite le journal de temps)",
|
||||||
|
"invoice_company_profile": "Profil de l'entreprise",
|
||||||
|
"invoice_company_name": "Nom de l'entreprise",
|
||||||
|
"invoice_company_address": "Adresse",
|
||||||
|
"invoice_company_phone": "Téléphone",
|
||||||
|
"invoice_company_email": "E-mail",
|
||||||
|
"invoice_company_tax_id": "Numéro fiscal",
|
||||||
|
"invoice_company_payment_details": "Détails de paiement",
|
||||||
|
"invoice_company_logo": "Logo",
|
||||||
|
"invoice_company_logo_choose": "Choisir un logo",
|
||||||
|
"invoice_company_logo_set": "Le logo a été défini",
|
||||||
|
"invoice_company_logo_not_set": "Logo non défini",
|
||||||
|
"invoice_number_unique": "Le numéro de facture doit être unique. Ce numéro de facture existe déjà.",
|
||||||
|
"invoice_invalid_amount": "Le montant est invalide",
|
||||||
|
"invoice_invalid_date_format": "Format de date invalide",
|
||||||
|
"invoice_invalid_tax_rate": "Le taux de TVA est invalide",
|
||||||
|
"invoice_no_items": "La facture ne contient aucun article",
|
||||||
|
"invoice_number_required": "Un numéro de facture est requis"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,22 @@ class MarkdownEditor(QTextEdit):
|
||||||
|
|
||||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||||
|
|
||||||
|
# ===== Collapsible sections (editor-only folding) =====
|
||||||
|
# We represent a collapsed region as:
|
||||||
|
# <indent>▸ collapse
|
||||||
|
# ... hidden blocks ...
|
||||||
|
# <indent><!-- bouquin:collapse:end -->
|
||||||
|
#
|
||||||
|
# The end-marker line is always hidden in the editor but preserved in markdown.
|
||||||
|
_COLLAPSE_ARROW_COLLAPSED = "▸"
|
||||||
|
_COLLAPSE_ARROW_EXPANDED = "▾"
|
||||||
|
_COLLAPSE_LABEL_COLLAPSE = "collapse"
|
||||||
|
_COLLAPSE_LABEL_EXPAND = "expand"
|
||||||
|
_COLLAPSE_END_MARKER = "<!-- bouquin:collapse:end -->"
|
||||||
|
# Accept either "collapse" or "expand" in the header text (older files used only "collapse")
|
||||||
|
_COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$")
|
||||||
|
_COLLAPSE_END_RE = re.compile(r"^([ \t]*)<!--\s*bouquin:collapse:end\s*-->\s*$")
|
||||||
|
|
||||||
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
@ -703,6 +719,9 @@ class MarkdownEditor(QTextEdit):
|
||||||
# Render any embedded images
|
# Render any embedded images
|
||||||
self._render_images()
|
self._render_images()
|
||||||
|
|
||||||
|
# Apply folding for any collapse regions present in the markdown
|
||||||
|
self._refresh_collapse_folding()
|
||||||
|
|
||||||
self._update_code_block_row_backgrounds()
|
self._update_code_block_row_backgrounds()
|
||||||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||||||
|
|
||||||
|
|
@ -1328,6 +1347,45 @@ class MarkdownEditor(QTextEdit):
|
||||||
block = cur.block()
|
block = cur.block()
|
||||||
text = block.text()
|
text = block.text()
|
||||||
|
|
||||||
|
# Click-to-toggle collapse regions: clicking the arrow on a
|
||||||
|
# "▸ collapse" / "▾ collapse" line expands/collapses the section.
|
||||||
|
parsed = self._parse_collapse_header(text)
|
||||||
|
if parsed:
|
||||||
|
indent, _is_collapsed = parsed
|
||||||
|
arrow_idx = len(indent)
|
||||||
|
if arrow_idx < len(text):
|
||||||
|
arrow = text[arrow_idx]
|
||||||
|
if arrow in (
|
||||||
|
self._COLLAPSE_ARROW_COLLAPSED,
|
||||||
|
self._COLLAPSE_ARROW_EXPANDED,
|
||||||
|
):
|
||||||
|
doc_pos = block.position() + arrow_idx
|
||||||
|
c_arrow = QTextCursor(self.document())
|
||||||
|
c_arrow.setPosition(
|
||||||
|
max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
doc_pos,
|
||||||
|
max(0, self.document().characterCount() - 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r = self.cursorRect(c_arrow)
|
||||||
|
|
||||||
|
fmt_font = (
|
||||||
|
c_arrow.charFormat().font()
|
||||||
|
if c_arrow.charFormat().isValid()
|
||||||
|
else self.font()
|
||||||
|
)
|
||||||
|
fm = QFontMetrics(fmt_font)
|
||||||
|
w = max(1, fm.horizontalAdvance(arrow))
|
||||||
|
|
||||||
|
# Make the hit area a bit generous.
|
||||||
|
hit_rect = QRect(r.x(), r.y(), w + (w // 2), r.height())
|
||||||
|
if hit_rect.contains(pt):
|
||||||
|
self._toggle_collapse_at_block(block)
|
||||||
|
return
|
||||||
|
|
||||||
# The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
|
# The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
|
||||||
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||||||
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
||||||
|
|
@ -1789,6 +1847,307 @@ class MarkdownEditor(QTextEdit):
|
||||||
cursor.insertImage(img_format)
|
cursor.insertImage(img_format)
|
||||||
cursor.insertText("\n") # Add newline after image
|
cursor.insertText("\n") # Add newline after image
|
||||||
|
|
||||||
|
# ========== Collapse / Expand (folding) ==========
|
||||||
|
|
||||||
|
def _parse_collapse_header(self, line: str) -> Optional[tuple[str, bool]]:
|
||||||
|
# If line is a collapse header, return (indent, is_collapsed)
|
||||||
|
m = self._COLLAPSE_HEADER_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
indent = m.group(1)
|
||||||
|
arrow = m.group(2)
|
||||||
|
return (indent, arrow == self._COLLAPSE_ARROW_COLLAPSED)
|
||||||
|
|
||||||
|
def _is_collapse_end_marker(self, line: str) -> bool:
|
||||||
|
return bool(self._COLLAPSE_END_RE.match(line))
|
||||||
|
|
||||||
|
def _set_block_visible(self, block: QTextBlock, visible: bool) -> None:
|
||||||
|
"""Hide/show a QTextBlock and nudge layout to update.
|
||||||
|
|
||||||
|
When folding, we set lineCount=0 for hidden blocks (standard Qt recipe).
|
||||||
|
When showing again, we restore a sensible lineCount based on the block's
|
||||||
|
current layout so the document relayout doesn't glitch.
|
||||||
|
"""
|
||||||
|
if not block.isValid():
|
||||||
|
return
|
||||||
|
if block.isVisible() == visible:
|
||||||
|
return
|
||||||
|
|
||||||
|
block.setVisible(visible)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not visible:
|
||||||
|
# Hidden blocks should contribute no height.
|
||||||
|
block.setLineCount(0) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
# Restore an accurate lineCount if we can.
|
||||||
|
layout = block.layout()
|
||||||
|
lc = 1
|
||||||
|
try:
|
||||||
|
lc = int(layout.lineCount()) if layout is not None else 1
|
||||||
|
except Exception:
|
||||||
|
lc = 1
|
||||||
|
block.setLineCount(max(1, lc)) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
doc = self.document()
|
||||||
|
if doc is not None:
|
||||||
|
doc.markContentsDirty(block.position(), block.length())
|
||||||
|
|
||||||
|
def _find_collapse_end_block(
|
||||||
|
self, header_block: QTextBlock
|
||||||
|
) -> Optional[QTextBlock]:
|
||||||
|
# Find matching end marker for a header (supports nesting)
|
||||||
|
if not header_block.isValid():
|
||||||
|
return None
|
||||||
|
|
||||||
|
depth = 1
|
||||||
|
b = header_block.next()
|
||||||
|
while b.isValid():
|
||||||
|
line = b.text()
|
||||||
|
if self._COLLAPSE_HEADER_RE.match(line):
|
||||||
|
depth += 1
|
||||||
|
elif self._is_collapse_end_marker(line):
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return b
|
||||||
|
b = b.next()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_collapse_header_state(
|
||||||
|
self, header_block: QTextBlock, collapsed: bool
|
||||||
|
) -> None:
|
||||||
|
parsed = self._parse_collapse_header(header_block.text())
|
||||||
|
if not parsed:
|
||||||
|
return
|
||||||
|
indent, _ = parsed
|
||||||
|
arrow = (
|
||||||
|
self._COLLAPSE_ARROW_COLLAPSED
|
||||||
|
if collapsed
|
||||||
|
else self._COLLAPSE_ARROW_EXPANDED
|
||||||
|
)
|
||||||
|
label = (
|
||||||
|
self._COLLAPSE_LABEL_EXPAND if collapsed else self._COLLAPSE_LABEL_COLLAPSE
|
||||||
|
)
|
||||||
|
new_line = f"{indent}{arrow} {label}"
|
||||||
|
|
||||||
|
# Replace *only* the text inside this block (not the paragraph separator),
|
||||||
|
# to avoid any chance of the header visually "joining" adjacent lines.
|
||||||
|
doc = self.document()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = QTextCursor(doc)
|
||||||
|
cursor.setPosition(header_block.position())
|
||||||
|
cursor.beginEditBlock()
|
||||||
|
cursor.movePosition(
|
||||||
|
QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor
|
||||||
|
)
|
||||||
|
cursor.insertText(new_line)
|
||||||
|
cursor.endEditBlock()
|
||||||
|
|
||||||
|
def _toggle_collapse_at_block(self, header_block: QTextBlock) -> None:
|
||||||
|
parsed = self._parse_collapse_header(header_block.text())
|
||||||
|
if not parsed:
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = self.document()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
block_num = header_block.blockNumber()
|
||||||
|
_, is_collapsed = parsed
|
||||||
|
|
||||||
|
end_block = self._find_collapse_end_block(header_block)
|
||||||
|
if end_block is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Flip header arrow
|
||||||
|
self._set_collapse_header_state(header_block, collapsed=not is_collapsed)
|
||||||
|
|
||||||
|
# Refresh folding so nested regions keep their state
|
||||||
|
self._refresh_collapse_folding()
|
||||||
|
|
||||||
|
# Re-resolve the header block after edits/layout changes
|
||||||
|
hb = doc.findBlockByNumber(block_num)
|
||||||
|
pos = hb.position() if hb.isValid() else header_block.position()
|
||||||
|
|
||||||
|
# Keep caret on the header (start of line)
|
||||||
|
c = self.textCursor()
|
||||||
|
c.setPosition(max(0, min(pos, max(0, doc.characterCount() - 1))))
|
||||||
|
self.setTextCursor(c)
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def _remove_collapse_at_block(self, header_block: QTextBlock) -> None:
|
||||||
|
# Remove a collapse wrapper (keep content, delete header + end marker)
|
||||||
|
end_block = self._find_collapse_end_block(header_block)
|
||||||
|
if end_block is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = self.document()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure content visible
|
||||||
|
b = header_block.next()
|
||||||
|
while b.isValid() and b != end_block:
|
||||||
|
self._set_block_visible(b, True)
|
||||||
|
b = b.next()
|
||||||
|
|
||||||
|
cur = QTextCursor(doc)
|
||||||
|
cur.beginEditBlock()
|
||||||
|
|
||||||
|
# Delete header block
|
||||||
|
cur.setPosition(header_block.position())
|
||||||
|
cur.select(QTextCursor.SelectionType.BlockUnderCursor)
|
||||||
|
cur.removeSelectedText()
|
||||||
|
cur.deleteChar() # paragraph separator
|
||||||
|
|
||||||
|
# Find and delete the end marker block (scan forward)
|
||||||
|
probe = doc.findBlock(end_block.position())
|
||||||
|
b2 = probe
|
||||||
|
for _ in range(0, 50):
|
||||||
|
if not b2.isValid():
|
||||||
|
break
|
||||||
|
if self._is_collapse_end_marker(b2.text()):
|
||||||
|
cur.setPosition(b2.position())
|
||||||
|
cur.select(QTextCursor.SelectionType.BlockUnderCursor)
|
||||||
|
cur.removeSelectedText()
|
||||||
|
cur.deleteChar()
|
||||||
|
break
|
||||||
|
b2 = b2.next()
|
||||||
|
|
||||||
|
cur.endEditBlock()
|
||||||
|
|
||||||
|
self._refresh_collapse_folding()
|
||||||
|
|
||||||
|
def collapse_selection(self) -> None:
|
||||||
|
# Wrap the current selection in a collapsible region and collapse it
|
||||||
|
cursor = self.textCursor()
|
||||||
|
if not cursor.hasSelection():
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = self.document()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
sel_start = min(cursor.selectionStart(), cursor.selectionEnd())
|
||||||
|
sel_end = max(cursor.selectionStart(), cursor.selectionEnd())
|
||||||
|
|
||||||
|
# Defensive clamp (prevents QTextCursor::setPosition out-of-range in edge cases)
|
||||||
|
doc_end = max(0, doc.characterCount() - 1)
|
||||||
|
sel_start = max(0, min(sel_start, doc_end))
|
||||||
|
sel_end = max(0, min(sel_end, doc_end))
|
||||||
|
|
||||||
|
c1 = QTextCursor(doc)
|
||||||
|
c1.setPosition(sel_start)
|
||||||
|
start_block = c1.block()
|
||||||
|
|
||||||
|
c2 = QTextCursor(doc)
|
||||||
|
c2.setPosition(sel_end)
|
||||||
|
end_block = c2.block()
|
||||||
|
|
||||||
|
# If the selection ends exactly at the start of a block, treat the
|
||||||
|
# previous block as the "end" (Qt selections often report the start
|
||||||
|
# of the next block as selectionEnd()).
|
||||||
|
if (
|
||||||
|
sel_end > sel_start
|
||||||
|
and end_block.isValid()
|
||||||
|
and sel_end == end_block.position()
|
||||||
|
and sel_end > 0
|
||||||
|
):
|
||||||
|
c2.setPosition(sel_end - 1)
|
||||||
|
end_block = c2.block()
|
||||||
|
|
||||||
|
# Expand to whole blocks
|
||||||
|
start_pos = start_block.position()
|
||||||
|
end_pos_raw = end_block.position() + end_block.length()
|
||||||
|
end_pos = min(end_pos_raw, max(0, doc.characterCount() - 1))
|
||||||
|
|
||||||
|
# Inherit indentation from the first selected line (useful inside lists)
|
||||||
|
m = re.match(r"^[ \t]*", start_block.text())
|
||||||
|
indent = m.group(0) if m else ""
|
||||||
|
|
||||||
|
header_line = (
|
||||||
|
f"{indent}{self._COLLAPSE_ARROW_COLLAPSED} {self._COLLAPSE_LABEL_EXPAND}"
|
||||||
|
)
|
||||||
|
end_marker_line = f"{indent}{self._COLLAPSE_END_MARKER}"
|
||||||
|
|
||||||
|
edit = QTextCursor(doc)
|
||||||
|
edit.beginEditBlock()
|
||||||
|
|
||||||
|
# Insert end marker AFTER selection first (keeps start positions stable)
|
||||||
|
edit.setPosition(end_pos)
|
||||||
|
|
||||||
|
# If the computed end position fell off the end of the document (common
|
||||||
|
# when the selection includes the last line without a trailing newline),
|
||||||
|
# ensure the end marker starts on its own line.
|
||||||
|
if end_pos_raw > end_pos and edit.position() > 0:
|
||||||
|
prev = doc.characterAt(edit.position() - 1)
|
||||||
|
if prev not in ("\n", "\u2029"):
|
||||||
|
edit.insertText("\n")
|
||||||
|
|
||||||
|
# Also ensure we are not mid-line (marker should be its own block).
|
||||||
|
if edit.position() > 0:
|
||||||
|
prev = doc.characterAt(edit.position() - 1)
|
||||||
|
if prev not in ("\n", "\u2029"):
|
||||||
|
edit.insertText("\n")
|
||||||
|
|
||||||
|
edit.insertText(end_marker_line + "\n")
|
||||||
|
|
||||||
|
# Insert header BEFORE selection
|
||||||
|
edit.setPosition(start_pos)
|
||||||
|
edit.insertText(header_line + "\n")
|
||||||
|
edit.endEditBlock()
|
||||||
|
|
||||||
|
self._refresh_collapse_folding()
|
||||||
|
|
||||||
|
# Caret on header
|
||||||
|
header_block = doc.findBlock(start_pos)
|
||||||
|
c = self.textCursor()
|
||||||
|
c.setPosition(header_block.position())
|
||||||
|
self.setTextCursor(c)
|
||||||
|
self.setFocus()
|
||||||
|
|
||||||
|
def _refresh_collapse_folding(self) -> None:
|
||||||
|
# Apply folding to all collapse regions based on their arrow state
|
||||||
|
doc = self.document()
|
||||||
|
if doc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show everything except end markers (always hidden)
|
||||||
|
b = doc.begin()
|
||||||
|
while b.isValid():
|
||||||
|
if self._is_collapse_end_marker(b.text()):
|
||||||
|
self._set_block_visible(b, False)
|
||||||
|
else:
|
||||||
|
self._set_block_visible(b, True)
|
||||||
|
b = b.next()
|
||||||
|
|
||||||
|
# Hide content for any header that is currently collapsed
|
||||||
|
b = doc.begin()
|
||||||
|
while b.isValid():
|
||||||
|
parsed = self._parse_collapse_header(b.text())
|
||||||
|
if parsed and parsed[1] is True:
|
||||||
|
end_block = self._find_collapse_end_block(b)
|
||||||
|
if end_block is None:
|
||||||
|
b = b.next()
|
||||||
|
continue
|
||||||
|
|
||||||
|
inner = b.next()
|
||||||
|
while inner.isValid() and inner != end_block:
|
||||||
|
self._set_block_visible(inner, False)
|
||||||
|
inner = inner.next()
|
||||||
|
|
||||||
|
self._set_block_visible(end_block, False)
|
||||||
|
b = end_block
|
||||||
|
b = b.next()
|
||||||
|
|
||||||
|
# Force a full relayout after visibility changes (prevents visual jitter)
|
||||||
|
doc.markContentsDirty(0, doc.characterCount())
|
||||||
|
self.viewport().update()
|
||||||
|
|
||||||
# ========== Context Menu Support ==========
|
# ========== Context Menu Support ==========
|
||||||
|
|
||||||
def contextMenuEvent(self, event):
|
def contextMenuEvent(self, event):
|
||||||
|
|
@ -1832,6 +2191,36 @@ class MarkdownEditor(QTextEdit):
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
|
# Collapse / Expand actions
|
||||||
|
header_parsed = self._parse_collapse_header(block.text())
|
||||||
|
if header_parsed:
|
||||||
|
_indent, is_collapsed = header_parsed
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
toggle_label = (
|
||||||
|
strings._("expand") if is_collapsed else strings._("collapse")
|
||||||
|
)
|
||||||
|
toggle_action = QAction(toggle_label, self)
|
||||||
|
toggle_action.triggered.connect(
|
||||||
|
lambda checked=False, b=block: self._toggle_collapse_at_block(b)
|
||||||
|
)
|
||||||
|
menu.addAction(toggle_action)
|
||||||
|
|
||||||
|
remove_action = QAction(strings._("remove_collapse"), self)
|
||||||
|
remove_action.triggered.connect(
|
||||||
|
lambda checked=False, b=block: self._remove_collapse_at_block(b)
|
||||||
|
)
|
||||||
|
menu.addAction(remove_action)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
if self.textCursor().hasSelection():
|
||||||
|
collapse_sel_action = QAction(strings._("collapse_selection"), self)
|
||||||
|
collapse_sel_action.triggered.connect(self.collapse_selection)
|
||||||
|
menu.addAction(collapse_sel_action)
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
# Add standard context menu actions
|
# Add standard context menu actions
|
||||||
if self.textCursor().hasSelection():
|
if self.textCursor().hasSelection():
|
||||||
menu.addAction(strings._("cut"), self.cut)
|
menu.addAction(strings._("cut"), self.cut)
|
||||||
|
|
|
||||||
|
|
@ -532,7 +532,7 @@ class SettingsDialog(QDialog):
|
||||||
def _on_choose_logo(self) -> None:
|
def _on_choose_logo(self) -> None:
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
strings._("company_logo_choose"),
|
strings._("invoice_company_logo_choose"),
|
||||||
"",
|
"",
|
||||||
"Images (*.png *.jpg *.jpeg *.bmp)",
|
"Images (*.png *.jpg *.jpeg *.bmp)",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1055,6 +1055,7 @@ class TimeReportDialog(QDialog):
|
||||||
self.range_preset.addItem(strings._("today"), "today")
|
self.range_preset.addItem(strings._("today"), "today")
|
||||||
self.range_preset.addItem(strings._("last_week"), "last_week")
|
self.range_preset.addItem(strings._("last_week"), "last_week")
|
||||||
self.range_preset.addItem(strings._("this_week"), "this_week")
|
self.range_preset.addItem(strings._("this_week"), "this_week")
|
||||||
|
self.range_preset.addItem(strings._("last_month"), "last_month")
|
||||||
self.range_preset.addItem(strings._("this_month"), "this_month")
|
self.range_preset.addItem(strings._("this_month"), "this_month")
|
||||||
self.range_preset.addItem(strings._("this_year"), "this_year")
|
self.range_preset.addItem(strings._("this_year"), "this_year")
|
||||||
self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed)
|
self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed)
|
||||||
|
|
@ -1214,6 +1215,12 @@ class TimeReportDialog(QDialog):
|
||||||
start = start_of_this_week.addDays(-7) # last week's Monday
|
start = start_of_this_week.addDays(-7) # last week's Monday
|
||||||
end = start_of_this_week.addDays(-1) # last week's Sunday
|
end = start_of_this_week.addDays(-1) # last week's Sunday
|
||||||
|
|
||||||
|
elif preset == "last_month":
|
||||||
|
# Previous calendar month (full month)
|
||||||
|
start_of_this_month = QDate(today.year(), today.month(), 1)
|
||||||
|
start = start_of_this_month.addMonths(-1)
|
||||||
|
end = start_of_this_month.addDays(-1)
|
||||||
|
|
||||||
elif preset == "this_month":
|
elif preset == "this_month":
|
||||||
start = QDate(today.year(), today.month(), 1)
|
start = QDate(today.year(), today.month(), 1)
|
||||||
end = today
|
end = today
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ class VersionChecker:
|
||||||
"""
|
"""
|
||||||
return self._parse_version(available) > self._parse_version(current)
|
return self._parse_version(available) > self._parse_version(current)
|
||||||
|
|
||||||
|
def _running_in_appimage(self) -> bool:
|
||||||
|
return "APPIMAGE" in os.environ
|
||||||
|
|
||||||
# ---------- Public entrypoint for Help → Version ---------- #
|
# ---------- Public entrypoint for Help → Version ---------- #
|
||||||
|
|
||||||
def show_version_dialog(self) -> None:
|
def show_version_dialog(self) -> None:
|
||||||
|
|
@ -114,8 +117,8 @@ class VersionChecker:
|
||||||
check_button = box.addButton(
|
check_button = box.addButton(
|
||||||
strings._("check_for_updates"), QMessageBox.ActionRole
|
strings._("check_for_updates"), QMessageBox.ActionRole
|
||||||
)
|
)
|
||||||
box.addButton(QMessageBox.Close)
|
|
||||||
|
|
||||||
|
box.addButton(QMessageBox.Close)
|
||||||
box.exec()
|
box.exec()
|
||||||
|
|
||||||
if box.clickedButton() is check_button:
|
if box.clickedButton() is check_button:
|
||||||
|
|
@ -159,21 +162,32 @@ class VersionChecker:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Newer version is available
|
# Newer version is available
|
||||||
reply = QMessageBox.question(
|
|
||||||
self._parent,
|
|
||||||
strings._("update"),
|
|
||||||
(
|
|
||||||
strings._("there_is_a_new_version_available")
|
|
||||||
+ available_raw
|
|
||||||
+ "\n\n"
|
|
||||||
+ strings._("download_the_appimage")
|
|
||||||
),
|
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
|
||||||
)
|
|
||||||
if reply != QMessageBox.Yes:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._download_and_verify_appimage(available_raw)
|
if self._running_in_appimage():
|
||||||
|
# If running in an AppImage, offer to download the new AppImage
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self._parent,
|
||||||
|
strings._("update"),
|
||||||
|
(
|
||||||
|
strings._("there_is_a_new_version_available")
|
||||||
|
+ available_raw
|
||||||
|
+ "\n\n"
|
||||||
|
+ strings._("download_the_appimage")
|
||||||
|
),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._download_and_verify_appimage(available_raw)
|
||||||
|
else:
|
||||||
|
# If not running in an AppImage, just report that there's a new version.
|
||||||
|
QMessageBox.information(
|
||||||
|
self._parent,
|
||||||
|
strings._("update"),
|
||||||
|
(strings._("there_is_a_new_version_available") + available_raw),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# ---------- Download + verification helpers ---------- #
|
# ---------- Download + verification helpers ---------- #
|
||||||
def _download_file(
|
def _download_file(
|
||||||
|
|
|
||||||
|
|
@ -173,45 +173,6 @@ def test_check_for_updates_already_latest(qtbot, app):
|
||||||
assert mock_info.called
|
assert mock_info.called
|
||||||
|
|
||||||
|
|
||||||
def test_check_for_updates_new_version_available_declined(qtbot, app):
|
|
||||||
"""Test check for updates when new version is available but user declines."""
|
|
||||||
parent = QWidget()
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
checker = VersionChecker(parent)
|
|
||||||
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.text = "2.0.0"
|
|
||||||
mock_response.raise_for_status = Mock()
|
|
||||||
|
|
||||||
with patch("requests.get", return_value=mock_response):
|
|
||||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
|
||||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
|
|
||||||
# Should not proceed to download
|
|
||||||
checker.check_for_updates()
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_for_updates_new_version_available_accepted(qtbot, app):
|
|
||||||
"""Test check for updates when new version is available and user accepts."""
|
|
||||||
parent = QWidget()
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
checker = VersionChecker(parent)
|
|
||||||
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.text = "2.0.0"
|
|
||||||
mock_response.raise_for_status = Mock()
|
|
||||||
|
|
||||||
with patch("requests.get", return_value=mock_response):
|
|
||||||
with patch("importlib.metadata.version", return_value="1.0.0"):
|
|
||||||
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
|
|
||||||
with patch.object(
|
|
||||||
checker, "_download_and_verify_appimage"
|
|
||||||
) as mock_download:
|
|
||||||
checker.check_for_updates()
|
|
||||||
|
|
||||||
# Should call download
|
|
||||||
mock_download.assert_called_once_with("2.0.0")
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_file_success(qtbot, app, tmp_path):
|
def test_download_file_success(qtbot, app, tmp_path):
|
||||||
"""Test downloading a file successfully."""
|
"""Test downloading a file successfully."""
|
||||||
checker = VersionChecker()
|
checker = VersionChecker()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue