Compare commits

..

No commits in common. "main" and "0.7.1" have entirely different histories.
main ... 0.7.1

16 changed files with 68 additions and 275 deletions

View file

@ -1,26 +0,0 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^bouquin/
args: ["-s", "B110"]

View file

@ -1,12 +1,3 @@
# 0.7.3
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
# 0.7.2
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
# 0.7.1
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)

View file

@ -92,7 +92,6 @@ class DBConfig:
idle_minutes: int = 15 # 0 = never lock
theme: str = "system"
move_todos: bool = False
move_todos_include_weekends: bool = False
tags: bool = True
time_log: bool = True
reminders: bool = True
@ -1352,7 +1351,7 @@ class DBManager:
project_id: int,
start_date_iso: str,
end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
) -> list[tuple[str, str, str, int]]:
"""
Return (time_period, activity_name, total_minutes) tuples between start and end
@ -1361,8 +1360,7 @@ class DBManager:
- 'YYYY-MM-DD' for day
- 'YYYY-WW' for week
- 'YYYY-MM' for month
For 'activity' granularity, results are grouped by activity only (no time bucket).
For 'none' granularity, each individual time log entry becomes a row.
For 'none' granularity, each individual time log entry becomes a row.
"""
cur = self.conn.cursor()
@ -1389,26 +1387,6 @@ class DBManager:
for r in rows
]
if granularity == "activity":
rows = cur.execute(
"""
SELECT
a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN activities a ON a.id = t.activity_id
WHERE t.project_id = ?
AND t.page_date BETWEEN ? AND ?
GROUP BY activity_name
ORDER BY LOWER(activity_name);
""",
(project_id, start_date_iso, end_date_iso),
).fetchall()
# period column is unused for activity grouping in the UI, but we keep
# the tuple shape consistent.
return [("", r["activity_name"], "", r["total_minutes"]) for r in rows]
if granularity == "day":
bucket_expr = "page_date"
elif granularity == "week":
@ -1439,14 +1417,11 @@ class DBManager:
self,
start_date_iso: str,
end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
) -> list[tuple[str, str, str, str, int]]:
"""
Return (project_name, time_period, activity_name, note, total_minutes)
across *all* projects between start and end.
- For 'day'/'week'/'month', grouped by project + period + activity.
- For 'activity', grouped by project + activity.
- For 'none', one row per time_log entry.
across *all* projects between start and end, grouped by project + period + activity.
"""
cur = self.conn.cursor()
@ -1480,34 +1455,6 @@ class DBManager:
for r in rows
]
if granularity == "activity":
rows = cur.execute(
"""
SELECT
p.name AS project_name,
a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN projects p ON p.id = t.project_id
JOIN activities a ON a.id = t.activity_id
WHERE t.page_date BETWEEN ? AND ?
GROUP BY p.id, activity_name
ORDER BY LOWER(p.name), LOWER(activity_name);
""",
(start_date_iso, end_date_iso),
).fetchall()
return [
(
r["project_name"],
"",
r["activity_name"],
"",
r["total_minutes"],
)
for r in rows
]
if granularity == "day":
bucket_expr = "page_date"
elif granularity == "week":

View file

@ -74,7 +74,7 @@ Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but

View file

@ -18,7 +18,7 @@ with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The

View file

@ -103,7 +103,6 @@
"autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images",
"images": "Images",
"reopen_failed": "Re-open failed",
@ -172,7 +171,7 @@
"stats_metric_revisions": "Revisions",
"stats_metric_documents": "Documents",
"stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents",
"stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
@ -210,7 +209,6 @@
"add_time_entry": "Add time entry",
"time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
@ -377,7 +375,7 @@
"documents_missing_file": "The file does not exist:\n{path}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"documents_search_placeholder": "Type to search documents (all projects)",
"todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices",

View file

@ -822,13 +822,9 @@ class MainWindow(QMainWindow):
Given a 'new day' (system date), return the date we should move
unfinished todos *to*.
By default, if the new day is Saturday or Sunday we skip ahead to the
next Monday (i.e., "next available weekday"). If the optional setting
`move_todos_include_weekends` is enabled, we move to the very next day
even if it's a weekend.
If the new day is Saturday or Sunday, we skip ahead to the next Monday.
Otherwise we just return the same day.
"""
if getattr(self.cfg, "move_todos_include_weekends", False):
return day
# Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7)
@ -1570,11 +1566,6 @@ class MainWindow(QMainWindow):
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.move_todos_include_weekends = getattr(
new_cfg,
"move_todos_include_weekends",
getattr(self.cfg, "move_todos_include_weekends", False),
)
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)

View file

@ -706,7 +706,7 @@ class ManageRemindersDialog(QDialog):
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(6)
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("text"),

View file

@ -42,9 +42,6 @@ def load_db_config() -> DBConfig:
idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool)
move_todos_include_weekends = s.value(
"ui/move_todos_include_weekends", False, type=bool
)
tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
@ -60,7 +57,6 @@ def load_db_config() -> DBConfig:
idle_minutes=idle,
theme=theme,
move_todos=move_todos,
move_todos_include_weekends=move_todos_include_weekends,
tags=tags,
time_log=time_log,
reminders=reminders,
@ -80,7 +76,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))

View file

@ -169,25 +169,6 @@ class SettingsDialog(QDialog):
self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos)
# Optional: allow moving to the very next day even if it is a weekend.
self.move_todos_include_weekends = QCheckBox(
strings._("move_todos_include_weekends")
)
self.move_todos_include_weekends.setChecked(
getattr(self.current_settings, "move_todos_include_weekends", False)
)
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
move_todos_opts = QWidget()
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
move_todos_opts_layout.setSpacing(4)
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
features_layout.addWidget(move_todos_opts)
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
self.tags = QCheckBox(strings._("enable_tags_feature"))
self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor)
@ -460,7 +441,6 @@ class SettingsDialog(QDialog):
idle_minutes=self.idle_spin.value(),
theme=selected_theme.value,
move_todos=self.move_todos.isChecked(),
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),

View file

@ -1083,7 +1083,6 @@ class TimeReportDialog(QDialog):
self.granularity.addItem(strings._("by_day"), "day")
self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month")
self.granularity.addItem(strings._("by_activity"), "activity")
form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form)
@ -1162,20 +1161,6 @@ class TimeReportDialog(QDialog):
header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
elif granularity == "activity":
# Grouped by activity only: no time period, no note column
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("hours"),
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
else:
# Grouped: no note column
self.table.setColumnCount(4)
@ -1287,21 +1272,16 @@ class TimeReportDialog(QDialog):
rows_for_table
):
hrs = minutes / 60.0
if self._last_gran == "activity":
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
else:
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
if self._last_gran == "none":
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
else:
# no note column
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
if self._last_gran == "none":
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
else:
# no note column
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
# Summary label - include per-project totals when in "all projects" mode
total_hours = self._last_total_minutes / 60.0
@ -1345,15 +1325,14 @@ class TimeReportDialog(QDialog):
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
gran = getattr(self, "_last_gran", "day")
show_note = gran == "none"
show_period = gran != "activity"
show_note = getattr(self, "_last_gran", "day") == "none"
# Header
header: list[str] = [strings._("project")]
if show_period:
header.append(strings._("time_period"))
header.append(strings._("activity"))
header = [
strings._("project"),
strings._("time_period"),
strings._("activity"),
]
if show_note:
header.append(strings._("note"))
header.append(strings._("hours"))
@ -1368,22 +1347,16 @@ class TimeReportDialog(QDialog):
minutes,
) in self._last_rows:
hours = minutes / 60.0
row: list[str] = [project]
if show_period:
row.append(time_period)
row.append(activity_name)
row = [project, time_period, activity_name]
if show_note:
row.append(note or "")
row.append(note)
row.append(f"{hours:.2f}")
writer.writerow(row)
# Blank line + total
total_hours = self._last_total_minutes / 60.0
writer.writerow([])
total_row = [""] * len(header)
total_row[0] = strings._("total")
total_row[-1] = f"{total_hours:.2f}"
writer.writerow(total_row)
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
except OSError as exc:
QMessageBox.warning(
self,
@ -1411,20 +1384,17 @@ class TimeReportDialog(QDialog):
if not filename.endswith(".pdf"):
filename = f"{filename}.pdf"
# ---------- Build chart image ----------
# Default: hours per time period. If grouped by activity: hours per activity.
gran = getattr(self, "_last_gran", "day")
per_bucket_minutes: dict[str, int] = defaultdict(int)
for _project, period, activity, _note, minutes in self._last_rows:
bucket = activity if gran == "activity" else period
per_bucket_minutes[bucket] += minutes
# ---------- Build chart image (hours per period) ----------
per_period_minutes: dict[str, int] = defaultdict(int)
for _project, period, _activity, note, minutes in self._last_rows:
per_period_minutes[period] += minutes
buckets = sorted(per_bucket_minutes.keys())
periods = sorted(per_period_minutes.keys())
chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white)
if buckets:
if periods:
painter = QPainter(chart)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
@ -1452,9 +1422,9 @@ class TimeReportDialog(QDialog):
# Border
painter.drawRect(left, top, width, height)
max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
max_hours = max(per_period_minutes[p] for p in periods) / 60.0
if max_hours > 0:
n = len(buckets)
n = len(periods)
bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6
@ -1479,8 +1449,8 @@ class TimeReportDialog(QDialog):
painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen)
for i, label in enumerate(buckets):
hours = per_bucket_minutes[label] / 60.0
for i, period in enumerate(periods):
hours = per_period_minutes[period] / 60.0
bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0:
continue # pragma: no cover
@ -1493,7 +1463,7 @@ class TimeReportDialog(QDialog):
# X labels after bars, in black
painter.setPen(Qt.black)
for i, label in enumerate(buckets):
for i, period in enumerate(periods):
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
painter.drawText(
@ -1502,7 +1472,7 @@ class TimeReportDialog(QDialog):
int(bar_width),
20,
Qt.AlignHCenter | Qt.AlignTop,
label,
period,
)
finally:
painter.end()
@ -1511,54 +1481,24 @@ class TimeReportDialog(QDialog):
project = html.escape(self._last_project_name or "")
start = html.escape(self._last_start or "")
end = html.escape(self._last_end or "")
gran_key = getattr(self, "_last_gran", "day")
gran_label = html.escape(self._last_gran_label or "")
gran = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0
# Table rows
# Table rows (period, activity, hours)
row_html_parts: list[str] = []
if gran_key == "activity":
for project, _period, activity, _note, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
else:
for project, period, activity, _note, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
for project, period, activity, note, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
rows_html = "\n".join(row_html_parts)
if gran_key == "activity":
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</tr>"
)
else:
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('time_period'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</tr>"
)
html_doc = f"""
<!DOCTYPE html>
<html>
@ -1604,11 +1544,16 @@ class TimeReportDialog(QDialog):
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
<p class="meta">
{html.escape(strings._("time_log_report_meta").format(
start=start, end=end, granularity=gran_label))}
start=start, end=end, granularity=gran))}
</p>
<p><img src="chart" class="chart" /></p>
<table>
{table_header_html}
<tr>
<th>{html.escape(strings._("project"))}</th>
<th>{html.escape(strings._("time_period"))}</th>
<th>{html.escape(strings._("activity"))}</th>
<th>{html.escape(strings._("hours"))}</th>
</tr>
{rows_html}
</table>
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>

6
poetry.lock generated
View file

@ -747,13 +747,13 @@ files = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.1"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
{file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
{file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
]
[package.extras]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.7.3"
version = "0.7.1"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"

View file

@ -2,34 +2,6 @@
set -eo pipefail
# Parse the args
while getopts "v:" OPTION
do
case $OPTION in
v)
VERSION=$OPTARG
;;
?)
usage
exit
;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "You forgot to pass -v [version]!"
exit 1
fi
set +e
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
git add pyproject.toml
git commit -m "Bump to ${VERSION}"
git push origin main
set -e
# Clean caches etc
filedust -y .
@ -38,11 +10,11 @@ poetry build
poetry publish
# Make AppImage
sudo apt-get -y install libfuse-dev
sudo apt-get install libfuse-dev
poetry run pyproject-appimage
mv Bouquin.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
echo "Don't forget to update version string on remote server."

View file

@ -1574,7 +1574,7 @@ def test_markdown_highlighter_special_characters(qtbot, app):
highlighter = MarkdownHighlighter(doc, theme_manager)
text = """
Special chars: < > & " '
Special chars: < > & " '
Escaped: \\* \\_ \\`
Unicode: 你好 café résumé
"""

View file

@ -1185,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 1
assert dialog.granularity.count() == 5
assert dialog.granularity.count() == 4
def test_time_report_dialog_loads_projects(qtbot, fresh_db):