Compare commits

..

7 commits

Author SHA1 Message Date
1becb7900e
Prevent traceback on trying to edit a tag with the same name as another tag. Various other tweaks. Bump version
All checks were successful
CI / test (push) Successful in 3m31s
Lint / test (push) Successful in 16s
Trivy / test (push) Successful in 21s
2025-11-14 17:30:58 +11:00
02a60ca656
Fix tests, add vulture_ignorelist.py, fix markdown_editor highlighter bug 2025-11-14 16:16:27 +11:00
f6e10dccac
Tags working 2025-11-14 14:54:04 +11:00
3263788415
Merge branch 'main' into tags 2025-11-14 13:54:46 +11:00
5e283ecf17
WIP 2025-11-14 13:18:58 +11:00
df7ae0b42d
Merge branch 'main' into tags 2025-11-14 12:12:38 +11:00
0a04b25fe5
Early work on tags 2025-11-13 20:37:02 +11:00
26 changed files with 4034 additions and 83 deletions

View file

@ -15,7 +15,7 @@ jobs:
run: | run: |
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
black pyflakes3 black pyflakes3 vulture
- name: Run linters - name: Run linters
run: | run: |
@ -23,3 +23,4 @@ jobs:
black --diff --check tests/* black --diff --check tests/*
pyflakes3 bouquin/* pyflakes3 bouquin/*
pyflakes3 tests/* pyflakes3 tests/*
vulture

View file

@ -1,13 +1,14 @@
# 0.2.1.9 # 0.3
* Fix a few small matters identified with tests * Introduce Tags
* Make locales dynamically detected from the locales dir rather than hardcoded * Make translations dynamically detected from the locales dir rather than hardcoded
* Add Italian translations (thanks @mdaleo404)
* Add version information in the navigation * Add version information in the navigation
* Increase line spacing between lines (except for code blocks) * Increase line spacing between lines (except for code blocks)
* Add Italian translations (thanks @mdaleo404)
* Prevent being able to left-click a date and have it load in current tab if it is already open in another tab * Prevent being able to left-click a date and have it load in current tab if it is already open in another tab
* Avoid second checkbox/bullet on second newline after first newline * Avoid second checkbox/bullet on second newline after first newline
* Avoid Home/left arrow jumping to the left side of a list symbol * Avoid Home/left arrow jumping to the left side of a list symbol
* Various test additions/fixes
# 0.2.1.8 # 0.2.1.8

View file

@ -29,6 +29,7 @@ There is deliberately no network connectivity or syncing intended.
* 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.
* Images are supported * Images are supported
* Search all pages, or find text on page (Ctrl+F) * Search all pages, or find text on page (Ctrl+F)
* Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours
* Automatic periodic saving (or explicitly save) * Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens * Transparent integrity checking of the database when it opens
* 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)
@ -37,7 +38,8 @@ There is deliberately no network connectivity or syncing intended.
* 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 themes * Dark and light themes
* Automatically generate checkboxes when typing 'TODO' * Automatically generate checkboxes when typing 'TODO'
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
* English, French and Italian locales provided
## How to install ## How to install

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import hashlib
import html import html
import json import json
@ -9,9 +10,34 @@ from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple from typing import List, Sequence, Tuple
from . import strings from . import strings
Entry = Tuple[str, str] Entry = Tuple[str, str]
TagRow = Tuple[int, str, str]
_TAG_COLORS = [
"#FFB3BA", # soft red
"#FFDFBA", # soft orange
"#FFFFBA", # soft yellow
"#BAFFC9", # soft green
"#BAE1FF", # soft blue
"#E0BAFF", # soft purple
"#FFC4B3", # soft coral
"#FFD8B1", # soft peach
"#FFF1BA", # soft light yellow
"#E9FFBA", # soft lime
"#CFFFE5", # soft mint
"#BAFFF5", # soft aqua
"#BAF0FF", # soft cyan
"#C7E9FF", # soft sky blue
"#C7CEFF", # soft periwinkle
"#F0BAFF", # soft lavender pink
"#FFBAF2", # soft magenta
"#FFD1F0", # soft pink
"#EBD5C7", # soft beige
"#EAEAEA", # soft gray
]
@dataclass @dataclass
@ -82,7 +108,6 @@ class DBManager:
# Always keep FKs on # Always keep FKs on
cur.execute("PRAGMA foreign_keys = ON;") cur.execute("PRAGMA foreign_keys = ON;")
# Create new versioned schema if missing (< 0.1.5)
cur.executescript( cur.executescript(
""" """
CREATE TABLE IF NOT EXISTS pages ( CREATE TABLE IF NOT EXISTS pages (
@ -103,6 +128,24 @@ class DBManager:
CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no); CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at); CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name);
CREATE TABLE IF NOT EXISTS page_tags (
page_date TEXT NOT NULL, -- FK to pages.date
tag_id INTEGER NOT NULL, -- FK to tags.id
PRIMARY KEY (page_date, tag_id),
FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id);
""" """
) )
self.conn.commit() self.conn.commit()
@ -142,22 +185,31 @@ class DBManager:
def search_entries(self, text: str) -> list[str]: def search_entries(self, text: str) -> list[str]:
""" """
Search for entries by term. This only works against the latest Search for entries by term or tag name.
version of the page. This only works against the latest version of the page.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
pattern = f"%{text}%" q = text.strip()
pattern = f"%{q.lower()}%"
rows = cur.execute( rows = cur.execute(
""" """
SELECT p.date, v.content SELECT DISTINCT p.date, v.content
FROM pages AS p FROM pages AS p
JOIN versions AS v JOIN versions AS v
ON v.id = p.current_version_id ON v.id = p.current_version_id
LEFT JOIN page_tags pt
ON pt.page_date = p.date
LEFT JOIN tags t
ON t.id = pt.tag_id
WHERE TRIM(v.content) <> '' WHERE TRIM(v.content) <> ''
AND v.content LIKE LOWER(?) ESCAPE '\\' AND (
LOWER(v.content) LIKE ?
OR LOWER(COALESCE(t.name, '')) LIKE ?
)
ORDER BY p.date DESC; ORDER BY p.date DESC;
""", """,
(pattern,), (pattern, pattern),
).fetchall() ).fetchall()
return [(r[0], r[1]) for r in rows] return [(r[0], r[1]) for r in rows]
@ -386,6 +438,184 @@ class DBManager:
except Exception as e: except Exception as e:
print(f"{strings._('error')}: {e}") print(f"{strings._('error')}: {e}")
# -------- Tags: helpers -------------------------------------------
def _default_tag_colour(self, name: str) -> str:
"""
Deterministically pick a colour for a tag name from a small palette.
"""
if not name:
return "#CCCCCC"
h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16)
return _TAG_COLORS[h % len(_TAG_COLORS)]
# -------- Tags: per-page -------------------------------------------
def get_tags_for_page(self, date_iso: str) -> list[TagRow]:
"""
Return (id, name, color) for all tags attached to this page/date.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT t.id, t.name, t.color
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.page_date = ?
ORDER BY LOWER(t.name);
""",
(date_iso,),
).fetchall()
return [(r[0], r[1], r[2]) for r in rows]
def set_tags_for_page(self, date_iso: str, tag_names: Sequence[str]) -> None:
"""
Replace the tag set for a page with the given names.
Creates new tags as needed (with auto colours).
Tags are case-insensitive - reuses existing tag if found with different case.
"""
# Normalise + dedupe (case-insensitive)
clean_names = []
seen = set()
for name in tag_names:
name = name.strip()
if not name:
continue
if name.lower() in seen:
continue
seen.add(name.lower())
clean_names.append(name)
with self.conn:
cur = self.conn.cursor()
# Ensure the page row exists even if there's no content yet
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
if not clean_names:
# Just clear all tags for this page
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
return
# For each tag name, check if it exists with different casing
# If so, reuse that existing tag; otherwise create new
final_tag_names = []
for name in clean_names:
# Look for existing tag (case-insensitive)
existing = cur.execute(
"SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,)
).fetchone()
if existing:
# Use the existing tag's exact name
final_tag_names.append(existing["name"])
else:
# Create new tag with the provided casing
cur.execute(
"""
INSERT OR IGNORE INTO tags(name, color)
VALUES (?, ?);
""",
(name, self._default_tag_colour(name)),
)
final_tag_names.append(name)
# Lookup ids for the final tag names
placeholders = ",".join("?" for _ in final_tag_names)
rows = cur.execute(
f"""
SELECT id, name
FROM tags
WHERE name IN ({placeholders});
""",
tuple(final_tag_names),
).fetchall()
ids_by_name = {r["name"]: r["id"] for r in rows}
# Reset page_tags for this page
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
for name in final_tag_names:
tag_id = ids_by_name.get(name)
if tag_id is not None:
cur.execute(
"""
INSERT OR IGNORE INTO page_tags(page_date, tag_id)
VALUES (?, ?);
""",
(date_iso, tag_id),
)
# -------- Tags: global management ----------------------------------
def list_tags(self) -> list[TagRow]:
"""
Return all tags in the database.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT id, name, color
FROM tags
ORDER BY LOWER(name);
"""
).fetchall()
return [(r[0], r[1], r[2]) for r in rows]
def update_tag(self, tag_id: int, name: str, color: str) -> None:
"""
Update a tag's name and colour.
"""
name = name.strip()
color = color.strip() or "#CCCCCC"
try:
with self.conn:
cur = self.conn.cursor()
cur.execute(
"""
UPDATE tags
SET name = ?, color = ?
WHERE id = ?;
""",
(name, color, tag_id),
)
except sqlite.IntegrityError as e:
if "UNIQUE constraint failed: tags.name" in str(e):
raise sqlite.IntegrityError(
strings._("tag_already_exists_with_that_name")
) from e
def delete_tag(self, tag_id: int) -> None:
"""
Delete a tag entirely (removes it from all pages).
"""
with self.conn:
cur = self.conn.cursor()
cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,))
cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,))
def get_pages_for_tag(self, tag_name: str) -> list[Entry]:
"""
Return (date, content) for pages that have the given tag.
"""
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT p.date, v.content
FROM pages AS p
JOIN versions AS v
ON v.id = p.current_version_id
JOIN page_tags pt
ON pt.page_date = p.date
JOIN tags t
ON t.id = pt.tag_id
WHERE LOWER(t.name) = LOWER(?)
ORDER BY p.date DESC;
""",
(tag_name,),
).fetchall()
return [(r[0], r[1]) for r in rows]
def close(self) -> None: def close(self) -> None:
if self.conn is not None: if self.conn is not None:
self.conn.close() self.conn.close()

88
bouquin/flow_layout.py Normal file
View file

@ -0,0 +1,88 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout
class FlowLayout(QLayout):
def __init__(
self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4
):
super().__init__(parent)
self._items = []
self._hspace = hspacing
self._vspace = vspacing
self.setContentsMargins(margin, margin, margin, margin)
def addItem(self, item):
self._items.append(item)
def itemAt(self, index):
if 0 <= index < len(self._items):
return self._items[index]
return None
def takeAt(self, index):
if 0 <= index < len(self._items):
return self._items.pop(index)
return None
def count(self):
return len(self._items)
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width: int) -> int:
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def sizeHint(self) -> QSize:
return self.minimumSize()
def minimumSize(self) -> QSize:
size = QSize()
for item in self._items:
size = size.expandedTo(item.minimumSize())
left, top, right, bottom = self.getContentsMargins()
size += QSize(left + right, top + bottom)
return size
def _do_layout(self, rect: QRect, test_only: bool) -> int:
x = rect.x()
y = rect.y()
line_height = 0
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(+left, +top, -right, -bottom)
x = effective_rect.x()
y = effective_rect.y()
max_right = effective_rect.right()
for item in self._items:
wid = item.widget()
if wid is None or not wid.isVisible():
continue
space_x = self._hspace
space_y = self._vspace
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > max_right and line_height > 0:
# Wrap
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom

View file

@ -110,5 +110,27 @@
"toolbar_numbered_list": "Numbered list", "toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block", "toolbar_code_block": "Code block",
"toolbar_heading": "Heading", "toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes" "toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"tag_name": "Tag name",
"tag_color_hex": "Hex colour",
"color_hex": "Colour",
"date": "Date",
"pick_color": "Pick colour",
"invalid_color_title": "Invalid colour",
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
"add": "Add",
"remove": "Remove",
"ok": "OK",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name"
} }

View file

@ -110,5 +110,27 @@
"toolbar_numbered_list": "Liste numérotée", "toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code", "toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre", "toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases" "toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Étiquettes",
"tag": "Étiquette",
"manage_tags": "Gérer les étiquettes",
"add_tag_placeholder": "Ajouter une étiquette et appuyez sur Entrée",
"tag_browser_title": "Navigateur de étiquettes",
"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.",
"tag_name": "Nom de l'étiquette",
"tag_color_hex": "Couleur hexadécimale",
"color_hex": "Couleur",
"date": "Date",
"pick_color": "Choisir la couleur",
"invalid_color_title": "Couleur invalide",
"invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
"add": "Ajouter",
"remove": "Supprimer",
"ok": "OK",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà"
} }

View file

@ -110,5 +110,26 @@
"toolbar_numbered_list": "Elenco numerato", "toolbar_numbered_list": "Elenco numerato",
"toolbar_code_block": "Blocco di codice", "toolbar_code_block": "Blocco di codice",
"toolbar_heading": "Titolo", "toolbar_heading": "Titolo",
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo" "toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
"tags": "Tag",
"manage_tags": "Gestisci tag",
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"tag_name": "Nome del tag",
"tag_color_hex": "Colore esadecimale",
"color_hex": "Colore",
"date": "Data",
"pick_color": "Scegli colore",
"invalid_color_title": "Colore non valido",
"invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
"add": "Aggiungi",
"remove": "Rimuovi",
"ok": "OK",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine.",
"tag_already_exists_with_that_name": "Esiste già un tag con questo nome"
} }

View file

@ -56,6 +56,7 @@ from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from . import strings from . import strings
from .tags_widget import PageTagsWidget
from .toolbar import ToolBar from .toolbar import ToolBar
from .theme import ThemeManager from .theme import ThemeManager
@ -93,6 +94,10 @@ class MainWindow(QMainWindow):
self.search.openDateRequested.connect(self._load_selected_date) self.search.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed) self.search.resultDatesChanged.connect(self._on_search_dates_changed)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
# Lock the calendar to the left panel at the top to stop it stretching # Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized. # when the main window is resized.
left_panel = QWidget() left_panel = QWidget()
@ -100,6 +105,7 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar) left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search) left_layout.addWidget(self.search)
left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# Create tab widget to hold multiple editors # Create tab widget to hold multiple editors
@ -203,6 +209,10 @@ class MainWindow(QMainWindow):
act_backup.setShortcut("Ctrl+Shift+B") act_backup.setShortcut("Ctrl+Shift+B")
act_backup.triggered.connect(self._backup) act_backup.triggered.connect(self._backup)
file_menu.addAction(act_backup) file_menu.addAction(act_backup)
act_tags = QAction("&" + strings._("manage_tags"), self)
act_tags.setShortcut("Ctrl+T")
act_tags.triggered.connect(self.tags._open_manager)
file_menu.addAction(act_tags)
file_menu.addSeparator() file_menu.addSeparator()
act_quit = QAction("&" + strings._("quit"), self) act_quit = QAction("&" + strings._("quit"), self)
act_quit.setShortcut("Ctrl+Q") act_quit.setShortcut("Ctrl+Q")
@ -498,6 +508,10 @@ class MainWindow(QMainWindow):
with QSignalBlocker(self.calendar): with QSignalBlocker(self.calendar):
self.calendar.setSelectedDate(editor.current_date) self.calendar.setSelectedDate(editor.current_date)
# update per-page tags for the active tab
date_iso = editor.current_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Reconnect toolbar to new active editor # Reconnect toolbar to new active editor
self._sync_toolbar() self._sync_toolbar()
@ -641,6 +655,9 @@ class MainWindow(QMainWindow):
# Keep tabs sorted by date # Keep tabs sorted by date
self._reorder_tabs_by_date() self._reorder_tabs_by_date()
# sync tags
self._update_tag_views_for_date(date_iso)
def _load_date_into_editor(self, date: QDate, extra_data=False): def _load_date_into_editor(self, date: QDate, extra_data=False):
"""Load a specific date's content into a given editor.""" """Load a specific date's content into a given editor."""
date_iso = date.toString("yyyy-MM-dd") date_iso = date.toString("yyyy-MM-dd")
@ -798,6 +815,10 @@ class MainWindow(QMainWindow):
if current_index >= 0: if current_index >= 0:
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd")) self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
# Update tags for the newly loaded page
date_iso = new_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Keep tabs sorted by date # Keep tabs sorted by date
self._reorder_tabs_by_date() self._reorder_tabs_by_date()
@ -1014,6 +1035,59 @@ class MainWindow(QMainWindow):
for path_str in paths: for path_str in paths:
self.editor.insert_image_from_path(Path(path_str)) self.editor.insert_image_from_path(Path(path_str))
# ----------- Tags handler ----------------#
def _update_tag_views_for_date(self, date_iso: str):
if hasattr(self, "tags"):
self.tags.set_current_date(date_iso)
def _on_tag_added(self):
"""Called when a tag is added - trigger autosave for current page"""
# Use QTimer to defer the save slightly, avoiding re-entrancy issues
from PySide6.QtCore import QTimer
QTimer.singleShot(0, self._do_tag_save)
def _do_tag_save(self):
"""Actually perform the save after tag is added"""
if hasattr(self, "editor") and hasattr(self.editor, "current_date"):
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Get current editor content
text = self.editor.to_markdown()
# Save the content (or blank if page is empty)
# This ensures the page shows up in tag browser
self.db.save_new_version(date_iso, text, note="Tag added")
self._dirty = False
self._refresh_calendar_marks()
from datetime import datetime as _dt
self.statusBar().showMessage(
strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}",
2000,
)
def _on_tag_activated(self, tag_name_or_date: str):
# If it's a date (YYYY-MM-DD format), load it
if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2:
self._load_selected_date(tag_name_or_date)
else:
# It's a tag name, open the tag browser
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date)
dlg.openDateRequested.connect(self._load_selected_date)
dlg.tagsModified.connect(self._refresh_current_page_tags)
dlg.exec()
def _refresh_current_page_tags(self):
"""Refresh the tag chips for the current page (after tag browser changes)"""
if hasattr(self, "tags") and hasattr(self.editor, "current_date"):
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
self.tags.set_current_date(date_iso)
if self.tags.toggle_btn.isChecked():
self.tags._reload_tags()
# ----------- Settings handler ------------# # ----------- Settings handler ------------#
def _open_settings(self): def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self) dlg = SettingsDialog(self.cfg, self.db, self)

View file

@ -73,9 +73,10 @@ class MarkdownEditor(QTextEdit):
def setDocument(self, doc): def setDocument(self, doc):
super().setDocument(doc) super().setDocument(doc)
# reattach the highlighter to the new document # Recreate the highlighter for the new document
if hasattr(self, "highlighter") and self.highlighter: # (the old one gets deleted with the old document)
self.highlighter.setDocument(self.document()) if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager)
self._apply_line_spacing() self._apply_line_spacing()
self._apply_code_block_spacing() self._apply_code_block_spacing()
QTimer.singleShot(0, self._update_code_block_row_backgrounds) QTimer.singleShot(0, self._update_code_block_row_backgrounds)
@ -97,7 +98,7 @@ class MarkdownEditor(QTextEdit):
line = block.text() line = block.text()
pos_in_block = c.position() - block.position() pos_in_block = c.position() - block.position()
# Transform markldown checkboxes and 'TODO' to unicode checkboxes # Transform markdown checkboxes and 'TODO' to unicode checkboxes
def transform_line(s: str) -> str: def transform_line(s: str) -> str:
s = s.replace( s = s.replace(
f"- {self._CHECK_CHECKED_STORAGE} ", f"- {self._CHECK_CHECKED_STORAGE} ",

View file

@ -199,7 +199,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.setFormat(end - 2, 2, self.syntax_format) self.setFormat(end - 2, 2, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.bold_format) self.setFormat(content_start, content_end - content_start, self.bold_format)
# --- Italic (*) or (_): skip if it overlaps any triple, keep your guards # --- Italic (*) or (_): skip if it overlaps any triple
for m in re.finditer( for m in re.finditer(
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
): ):

235
bouquin/tag_browser.py Normal file
View file

@ -0,0 +1,235 @@
# tag_browser.py
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
)
from .db import DBManager
from sqlcipher3.dbapi2 import IntegrityError
from . import strings
class TagBrowserDialog(QDialog):
openDateRequested = Signal(str)
tagsModified = Signal()
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(
strings._("tag_browser_title") + " / " + strings._("manage_tags")
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(strings._("tag_browser_instructions"))
instructions.setWordWrap(True)
layout.addWidget(instructions)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
self.tree.itemClicked.connect(self._on_item_clicked)
self.tree.setSortingEnabled(True)
self.tree.sortByColumn(0, Qt.AscendingOrder)
layout.addWidget(self.tree)
# Tag management buttons
btn_row = QHBoxLayout()
self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
self.edit_name_btn.clicked.connect(self._edit_tag_name)
self.edit_name_btn.setEnabled(False)
btn_row.addWidget(self.edit_name_btn)
self.change_color_btn = QPushButton(strings._("change_color"))
self.change_color_btn.clicked.connect(self._change_tag_color)
self.change_color_btn.setEnabled(False)
btn_row.addWidget(self.change_color_btn)
self.delete_btn = QPushButton(strings._("delete_tag"))
self.delete_btn.clicked.connect(self._delete_tag)
self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
# Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
# Disable sorting during population for better performance
was_sorting = self.tree.isSortingEnabled()
self.tree.setSortingEnabled(False)
self.tree.clear()
tags = self._db.list_tags()
focus_item = None
for tag_id, name, color in tags:
# Create the tree item
root = QTreeWidgetItem([name, "", ""])
root.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "tag", "id": tag_id, "name": name, "color": color},
)
# Set background color for the second column to show the tag color
bg_color = QColor(color)
root.setBackground(1, bg_color)
# Calculate luminance and set contrasting text color
# Using relative luminance formula (ITU-R BT.709)
luminance = (
0.2126 * bg_color.red()
+ 0.7152 * bg_color.green()
+ 0.0722 * bg_color.blue()
) / 255.0
text_color = QColor(0, 0, 0) if luminance > 0.5 else QColor(255, 255, 255)
root.setForeground(1, text_color)
root.setText(1, color) # Also show the hex code
root.setTextAlignment(1, Qt.AlignCenter)
self.tree.addTopLevelItem(root)
pages = self._db.get_pages_for_tag(name)
for date_iso, _content in pages:
child = QTreeWidgetItem(["", "", date_iso])
child.setData(
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
focus_item = root
if focus_item:
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
# Re-enable sorting after population
self.tree.setSortingEnabled(was_sorting)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Enable/disable buttons based on selection"""
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "tag":
self.edit_name_btn.setEnabled(True)
self.change_color_btn.setEnabled(True)
self.delete_btn.setEnabled(True)
else:
self.edit_name_btn.setEnabled(False)
self.change_color_btn.setEnabled(False)
self.delete_btn.setEnabled(False)
def _on_item_activated(self, item: QTreeWidgetItem, column: int):
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
def _edit_tag_name(self):
"""Edit the name of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
old_name = data["name"]
color = data["color"]
# Simple input dialog
from PySide6.QtWidgets import QInputDialog
new_name, ok = QInputDialog.getText(
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
)
if ok and new_name and new_name != old_name:
try:
self._db.update_tag(tag_id, new_name, color)
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _change_tag_color(self):
"""Change the color of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
current_color = data["color"]
color = QColorDialog.getColor(QColor(current_color), self)
if color.isValid():
try:
self._db.update_tag(tag_id, name, color.name())
self._populate(None)
self.tagsModified.emit()
except IntegrityError as e:
QMessageBox.critical(self, strings._("db_database_error"), str(e))
def _delete_tag(self):
"""Delete the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
# Confirm deletion
reply = QMessageBox.question(
self,
strings._("delete_tag"),
strings._("delete_tag_confirm").format(name=name),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_tag(tag_id)
self._populate(None)
self.tagsModified.emit()

259
bouquin/tags_widget.py Normal file
View file

@ -0,0 +1,259 @@
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QVBoxLayout,
QWidget,
QToolButton,
QLabel,
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
)
from . import strings
from .db import DBManager
from .flow_layout import FlowLayout
class TagChip(QFrame):
removeRequested = Signal(int) # tag_id
clicked = Signal(str) # tag name
def __init__(
self,
tag_id: int,
name: str,
color: str,
parent: QWidget | None = None,
show_remove: bool = True,
):
super().__init__(parent)
self._id = tag_id
self._name = name
self.setObjectName("TagChip")
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(4)
color_lbl = QLabel()
color_lbl.setFixedSize(10, 10)
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
layout.addWidget(color_lbl)
name_lbl = QLabel(name)
layout.addWidget(name_lbl)
if show_remove:
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
layout.addWidget(btn)
self.setCursor(Qt.PointingHandCursor)
@property
def tag_id(self) -> int:
return self._id
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self.clicked.emit(self._name)
try:
super().mouseReleaseEvent(ev)
except RuntimeError:
pass
class PageTagsWidget(QFrame):
"""
Collapsible per-page tag editor shown in the left sidebar.
Now displays tag chips even when collapsed.
"""
tagActivated = Signal(str) # tag name
tagAdded = Signal() # emitted when a tag is added to trigger autosave
def __init__(self, db: DBManager, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
self._current_date: Optional[str] = None
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + manage button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("tags"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip(strings._("manage_tags"))
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._open_manager)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.manage_btn)
# Body (chips + add line - only visible when expanded)
self.body = QWidget()
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(0, 4, 0, 0)
self.body_layout.setSpacing(4)
# Chips container
self.chip_container = QWidget()
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
self.body_layout.addWidget(self.chip_container)
self.add_edit = QLineEdit()
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
self.add_edit.returnPressed.connect(self._on_add_tag)
# Setup autocomplete
self._setup_autocomplete()
self.body_layout.addWidget(self.add_edit)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# ----- external API ------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
# Only reload tags if expanded
if self.toggle_btn.isChecked():
self._reload_tags()
else:
self._clear_chips() # Clear chips when collapsed
self._setup_autocomplete() # Update autocomplete with all available tags
# ----- internals ---------------------------------------------------
def _setup_autocomplete(self) -> None:
"""Setup autocomplete for the tag input with all existing tags"""
all_tags = [name for _, name, _ in self._db.list_tags()]
completer = QCompleter(all_tags, self.add_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.add_edit.setCompleter(completer)
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
if self._current_date:
self._reload_tags()
self.add_edit.setFocus()
def _clear_chips(self) -> None:
while self.chip_layout.count():
item = self.chip_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _reload_tags(self) -> None:
if not self._current_date:
self._clear_chips()
return
self._clear_chips()
tags = self._db.get_tags_for_page(self._current_date)
for tag_id, name, color in tags:
# Always show remove button since chips only visible when expanded
chip = TagChip(tag_id, name, color, self, show_remove=True)
chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_layout.addWidget(chip)
chip.show()
chip.adjustSize()
# Force complete layout recalculation
self.chip_layout.invalidate()
self.chip_layout.activate()
self.chip_container.updateGeometry()
self.updateGeometry()
# Process pending events to ensure layout is applied
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
def _on_add_tag(self) -> None:
if not self._current_date:
return
# If the completer popup is visible and user pressed Enter,
# the completer will handle it - don't process it again
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
return
new_tag = self.add_edit.text().strip()
if not new_tag:
return
# Get existing tags for current page
existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
# Check for duplicates (case-insensitive)
if any(tag.lower() == new_tag.lower() for tag in existing):
self.add_edit.clear()
return
existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear()
self._reload_tags()
self._setup_autocomplete() # Update autocomplete list
# Signal that a tag was added so main window can trigger autosave
self.tagAdded.emit()
def _remove_tag(self, tag_id: int) -> None:
if not self._current_date:
return
tags = self._db.get_tags_for_page(self._current_date)
remaining = [name for (tid, name, _color) in tags if tid != tag_id]
self._db.set_tags_for_page(self._current_date, remaining)
self._reload_tags()
def _open_manager(self) -> None:
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self._db, self)
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
if dlg.exec():
# Reload tags after manager closes to pick up any changes
if self._current_date:
self._reload_tags()
self._setup_autocomplete()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)

View file

@ -204,7 +204,7 @@ class ThemeManager(QObject):
) )
if is_dark: if is_dark:
# Use the link color as the accent (you set this to ORANGE in dark palette) # Use the link color as the accent
accent = pal.color(QPalette.Link) accent = pal.color(QPalette.Link)
r, g, b = accent.red(), accent.green(), accent.blue() r, g, b = accent.red(), accent.green(), accent.blue()
accent_hex = accent.name() accent_hex = accent.name()

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.2.1.8" version = "0.3"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"
@ -31,6 +31,9 @@ pyproject-appimage = "^4.2"
script = "bouquin" script = "bouquin"
output = "Bouquin.AppImage" output = "Bouquin.AppImage"
[tool.vulture]
paths = ["bouquin", "vulture_ignorelist.py"]
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View file

@ -16,7 +16,6 @@ def editor(app, qtbot):
return ed return ed
@pytest.mark.gui
def test_findbar_basic_navigation(qtbot, editor): def test_findbar_basic_navigation(qtbot, editor):
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n") editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start) editor.moveCursor(QTextCursor.Start)
@ -113,7 +112,6 @@ def test_update_highlight_clear_when_empty(qtbot, editor):
assert not editor.extraSelections() assert not editor.extraSelections()
@pytest.mark.gui
def test_maybe_hide_and_wrap_prev(qtbot, editor): def test_maybe_hide_and_wrap_prev(qtbot, editor):
editor.setPlainText("a a a") editor.setPlainText("a a a")
fb = FindBar(editor=editor, shortcut_parent=editor) fb = FindBar(editor=editor, shortcut_parent=editor)

View file

@ -1,11 +1,9 @@
import pytest
from PySide6.QtCore import QEvent from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
@pytest.mark.gui
def test_lock_overlay_reacts_to_theme(app, qtbot): def test_lock_overlay_reacts_to_theme(app, qtbot):
host = QWidget() host = QWidget()
qtbot.addWidget(host) qtbot.addWidget(host)

View file

@ -10,11 +10,12 @@ from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
from bouquin.db import DBConfig, DBManager from bouquin.db import DBConfig, DBManager
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent
from unittest.mock import Mock, patch
@pytest.mark.gui
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/path", str(tmp_db_cfg.path))
@ -79,7 +80,6 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
assert "carry me" not in y_txt or "- [ ]" not in y_txt assert "carry me" not in y_txt or "- [ ]" not in y_txt
@pytest.mark.gui
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes) w = MainWindow(themes=themes)
@ -113,7 +113,6 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch)
assert called["docs"] and called["bugs"] assert called["docs"] and called["bugs"]
@pytest.mark.gui
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
# Seed some content # Seed some content
fresh_db.save_new_version("2001-01-01", "alpha", "n1") fresh_db.save_new_version("2001-01-01", "alpha", "n1")
@ -190,7 +189,6 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
assert errs["hit"] assert errs["hit"]
@pytest.mark.gui
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -248,7 +246,6 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
assert str(dest.with_suffix(".db")) in hit["text"] assert str(dest.with_suffix(".db")) in hit["text"]
@pytest.mark.gui
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
from bouquin.settings import get_settings from bouquin.settings import get_settings
@ -283,7 +280,6 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
monkeypatch.delattr(w, "_save_editor_content", raising=False) monkeypatch.delattr(w, "_save_editor_content", raising=False)
@pytest.mark.gui
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch): def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes) w = MainWindow(themes=themes)
@ -329,7 +325,6 @@ def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
assert called["max"] assert called["max"]
@pytest.mark.gui
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch): def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
# Seed DB so refresh marks does something # Seed DB so refresh marks does something
fresh_db.save_new_version("2021-08-15", "note", "") fresh_db.save_new_version("2021-08-15", "note", "")
@ -402,7 +397,6 @@ def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch
w._show_calendar_context_menu(cal_pos) w._show_calendar_context_menu(cal_pos)
@pytest.mark.gui
def test_event_filter_keypress_starts_idle_timer(qtbot, app): def test_event_filter_keypress_starts_idle_timer(qtbot, app):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes) w = MainWindow(themes=themes)
@ -441,7 +435,7 @@ def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path):
# Avoid accidentaly creating DB by short-circuiting the prompt loop # Avoid accidentaly creating DB by short-circuiting the prompt loop
class MW(MainWindow): class MW(MainWindow):
def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802 def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802
assert first_time is True # hit line 73 path assert first_time is True
return False return False
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@ -944,7 +938,7 @@ def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch
# remove timer to hit early return # remove timer to hit early return
delattr(w, "_idle_timer") delattr(w, "_idle_timer")
w._apply_idle_minutes(5) # no crash => line 1176 branch w._apply_idle_minutes(5) # no crash
# re-create a timer and simulate locking then disabling idle # re-create a timer and simulate locking then disabling idle
w._idle_timer = QTimer(w) w._idle_timer = QTimer(w)
@ -1027,7 +1021,6 @@ def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch):
assert not w._rect_on_any_screen(far) assert not w._rect_on_any_screen(far)
@pytest.mark.gui
def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch): def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w) qtbot.addWidget(w)
@ -1103,7 +1096,6 @@ def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch
w._on_tab_changed(1) # should not raise w._on_tab_changed(1) # should not raise
@pytest.mark.gui
def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch): def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w) qtbot.addWidget(w)
@ -1124,7 +1116,6 @@ def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatc
assert w.tab_widget.count() == before assert w.tab_widget.count() == before
@pytest.mark.gui
def test_export_cancel_then_empty_filename( def test_export_cancel_then_empty_filename(
qtbot, app, tmp_db_cfg, monkeypatch, tmp_path qtbot, app, tmp_db_cfg, monkeypatch, tmp_path
): ):
@ -1187,7 +1178,6 @@ def test_export_cancel_then_empty_filename(
w._export() # returns early at filename check w._export() # returns early at filename check
@pytest.mark.gui
def test_set_editor_markdown_preserve_view_preserves( def test_set_editor_markdown_preserve_view_preserves(
qtbot, app, tmp_db_cfg, monkeypatch qtbot, app, tmp_db_cfg, monkeypatch
): ):
@ -1212,7 +1202,6 @@ def test_set_editor_markdown_preserve_view_preserves(
assert w.editor.to_markdown().endswith("extra\n") assert w.editor.to_markdown().endswith("extra\n")
@pytest.mark.gui
def test_load_date_into_editor_with_extra_data_forces_save( def test_load_date_into_editor_with_extra_data_forces_save(
qtbot, app, tmp_db_cfg, monkeypatch qtbot, app, tmp_db_cfg, monkeypatch
): ):
@ -1230,7 +1219,6 @@ def test_load_date_into_editor_with_extra_data_forces_save(
assert called["iso"] == "2020-01-01" and called["explicit"] is True assert called["iso"] == "2020-01-01" and called["explicit"] is True
@pytest.mark.gui
def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch): def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch):
"""Covers moveTab for both dated and undated buckets.""" """Covers moveTab for both dated and undated buckets."""
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
@ -1324,7 +1312,6 @@ def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch
assert w._date_from_calendar_pos(QPoint(5, 5)) is None assert w._date_from_calendar_pos(QPoint(5, 5)) is None
@pytest.mark.gui
def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch): def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch):
"""Covers DB not connected branch.""" """Covers DB not connected branch."""
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
@ -1337,7 +1324,6 @@ def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypa
w._save_editor_content(w.editor) w._save_editor_content(w.editor)
@pytest.mark.gui
def test_on_date_changed_stops_timer_and_saves_prev_when_dirty( def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
qtbot, app, tmp_db_cfg, monkeypatch qtbot, app, tmp_db_cfg, monkeypatch
): ):
@ -1370,7 +1356,6 @@ def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
assert saved["iso"] == "2024-01-01" assert saved["iso"] == "2024-01-01"
@pytest.mark.gui
def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch): def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
"""Covers early return when toolbar is already bound.""" """Covers early return when toolbar is already bound."""
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
@ -1380,7 +1365,6 @@ def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
w._bind_toolbar() w._bind_toolbar()
@pytest.mark.gui
def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch): def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch):
"""Covers the early return when user selects no files.""" """Covers the early return when user selects no files."""
w = _make_main_window(tmp_db_cfg, app, monkeypatch) w = _make_main_window(tmp_db_cfg, app, monkeypatch)
@ -1420,7 +1404,6 @@ def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeyp
assert hit["start"] assert hit["start"]
@pytest.mark.gui
def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch): def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch):
""" """
Covers exception swallowing around settings writes & ensures close proceeds Covers exception swallowing around settings writes & ensures close proceeds
@ -1488,3 +1471,328 @@ def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch):
w.closeEvent(ev) w.closeEvent(ev)
assert called["save"] and called["close"] assert called["save"] and called["close"]
# ============================================================================
# Tag Save Handler Tests
# ============================================================================
def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test _do_tag_save when editor has current_date"""
# Skip the key prompt
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Set a date on the editor
date = QDate(2024, 1, 15)
window.editor.current_date = date
window.editor.from_markdown("Test content")
# Call _do_tag_save
window._do_tag_save()
# Should have saved
fresh_db.get_entry("2024-01-15")
# May or may not have content depending on timing, but should not crash
assert True
def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test _do_tag_save when editor doesn't have current_date"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Remove current_date attribute
if hasattr(window.editor, "current_date"):
delattr(window.editor, "current_date")
# Call _do_tag_save - should handle gracefully
window._do_tag_save()
assert True
def test_main_window_on_tag_added_triggers_deferred_save(
app, fresh_db, tmp_db_cfg, monkeypatch
):
"""Test that _on_tag_added defers the save"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Mock QTimer.singleShot
with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer:
window._on_tag_added()
# Should have called singleShot
mock_timer.assert_called_once()
args = mock_timer.call_args[0]
assert args[0] == 0 # Delay of 0
assert callable(args[1]) # Callback function
# ============================================================================
# Tag Activation Tests
# ============================================================================
def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test _on_tag_activated when passed a date string"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Mock _load_selected_date
window._load_selected_date = Mock()
# Call with date format
window._on_tag_activated("2024-01-15")
# Should have called _load_selected_date
window._load_selected_date.assert_called_once_with("2024-01-15")
def test_main_window_on_tag_activated_with_tag_name(
app, fresh_db, tmp_db_cfg, monkeypatch
):
"""Test _on_tag_activated when passed a tag name"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Mock the tag browser dialog (it's imported locally in the method)
with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog:
mock_instance = Mock()
mock_instance.openDateRequested = Mock()
mock_instance.exec.return_value = QDialog.Accepted
mock_dialog.return_value = mock_instance
# Call with tag name
window._on_tag_activated("worktag")
# Should have opened dialog
mock_dialog.assert_called_once()
# Check focus_tag was passed
call_kwargs = mock_dialog.call_args[1]
assert call_kwargs.get("focus_tag") == "worktag"
# ============================================================================
# Settings Path Change Tests
# ============================================================================
def test_main_window_settings_path_change_success(
app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
):
"""Test changing database path in settings"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
new_path = tmp_path / "new.db"
# Mock the settings dialog
with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
mock_instance = Mock()
mock_instance.exec.return_value = QDialog.Accepted
# Create a new config with different path
new_cfg = Mock()
new_cfg.path = str(new_path)
new_cfg.key = tmp_db_cfg.key
new_cfg.idle_minutes = 15
new_cfg.theme = "light"
new_cfg.move_todos = True
new_cfg.locale = "en"
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
# Mock _prompt_for_key_until_valid to return True
window._prompt_for_key_until_valid = Mock(return_value=True)
# Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection
window._load_selected_date = Mock()
window._refresh_calendar_marks = Mock()
# Open settings
window._open_settings()
# Path should have changed
assert window.cfg.path == str(new_path)
def test_main_window_settings_path_change_failure(
app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
):
"""Test failed database path change shows warning"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
new_path = tmp_path / "new.db"
# Mock the settings dialog
with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
mock_instance = Mock()
mock_instance.exec.return_value = QDialog.Accepted
new_cfg = Mock()
new_cfg.path = str(new_path)
new_cfg.key = tmp_db_cfg.key
new_cfg.idle_minutes = 15
new_cfg.theme = "light"
new_cfg.move_todos = True
new_cfg.locale = "en"
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
# Mock _prompt_for_key_until_valid to return False (failure)
window._prompt_for_key_until_valid = Mock(return_value=False)
# Mock QMessageBox.warning
with patch.object(QMessageBox, "warning") as mock_warning:
# Open settings
window._open_settings()
# Warning should have been shown
mock_warning.assert_called_once()
def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test settings change without path change"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
old_path = window.cfg.path
# Mock the settings dialog
with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
mock_instance = Mock()
mock_instance.exec.return_value = QDialog.Accepted
# Create config with SAME path
new_cfg = Mock()
new_cfg.path = old_path
new_cfg.key = tmp_db_cfg.key
new_cfg.idle_minutes = 20 # Changed
new_cfg.theme = "dark" # Changed
new_cfg.move_todos = False # Changed
new_cfg.locale = "fr" # Changed
mock_instance.config = new_cfg
mock_dialog.return_value = mock_instance
# Open settings
window._open_settings()
# Settings should be updated but path didn't change
assert window.cfg.idle_minutes == 20
assert window.cfg.theme == "dark"
assert window.cfg.path == old_path
def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test cancelling settings dialog"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
old_theme = window.cfg.theme
# Mock the settings dialog to be rejected
with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
mock_instance = Mock()
mock_instance.exec.return_value = QDialog.Rejected
mock_dialog.return_value = mock_instance
# Open settings
window._open_settings()
# Settings should NOT change
assert window.cfg.theme == old_theme
# ============================================================================
# Update Tag Views Tests
# ============================================================================
def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch):
"""Test _update_tag_views_for_date"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Set tags for a date
fresh_db.set_tags_for_page("2024-01-15", ["test"])
# Update tag views
window._update_tag_views_for_date("2024-01-15")
# Tags widget should have been updated
assert window.tags._current_date == "2024-01-15"
def test_main_window_update_tag_views_no_tags_widget(
app, fresh_db, tmp_db_cfg, monkeypatch
):
"""Test _update_tag_views_for_date when tags widget doesn't exist"""
monkeypatch.setattr(
"bouquin.main_window.KeyPrompt",
lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes)
# Remove tags widget
delattr(window, "tags")
# Should handle gracefully
window._update_tag_views_for_date("2024-01-15")
assert True

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
import pytest
from bouquin.search import Search from bouquin.search import Search
from PySide6.QtWidgets import QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
@ -80,7 +79,6 @@ def test_make_html_snippet_variants(qtbot, fresh_db):
assert "<b>delta</b>" in frag assert "<b>delta</b>" in frag
@pytest.mark.gui
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch): def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
s = Search(fresh_db) s = Search(fresh_db)
qtbot.addWidget(s) qtbot.addWidget(s)
@ -92,7 +90,6 @@ def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
assert frag == "" and not left and not right assert frag == "" and not left and not right
@pytest.mark.gui
def test_populate_results_shows_both_ellipses(qtbot, fresh_db): def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
s = Search(fresh_db) s = Search(fresh_db)
qtbot.addWidget(s) qtbot.addWidget(s)

View file

@ -1,5 +1,3 @@
import pytest
from bouquin.db import DBManager, DBConfig from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
import bouquin.settings_dialog as sd import bouquin.settings_dialog as sd
@ -10,7 +8,6 @@ from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
@pytest.mark.gui
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...)) # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
app = QApplication.instance() app = QApplication.instance()
@ -206,7 +203,6 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
assert called["text"] assert called["text"]
@pytest.mark.gui
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch): def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
parent = QWidget() parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))

View file

@ -1,5 +1,4 @@
import types import types
import pytest
from PySide6.QtWidgets import QFileDialog from PySide6.QtWidgets import QFileDialog
from PySide6.QtGui import QTextCursor from PySide6.QtGui import QTextCursor
@ -10,7 +9,6 @@ from bouquin.main_window import MainWindow
from bouquin.history_dialog import HistoryDialog from bouquin.history_dialog import HistoryDialog
@pytest.mark.gui
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
# point to the temp encrypted DB # point to the temp encrypted DB
s = get_settings() s = get_settings()
@ -43,7 +41,6 @@ def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
assert w.tab_widget.currentWidget().current_date == date1 assert w.tab_widget.currentWidget().current_date == date1
@pytest.mark.gui
def test_toolbar_signals_dispatch_once_per_click( def test_toolbar_signals_dispatch_once_per_click(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
): ):
@ -115,7 +112,6 @@ def test_toolbar_signals_dispatch_once_per_click(
assert calls2["bold"] == 1 assert calls2["bold"] == 1
@pytest.mark.gui
def test_history_and_insert_image_not_duplicated( def test_history_and_insert_image_not_duplicated(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
): ):
@ -158,7 +154,6 @@ def test_history_and_insert_image_not_duplicated(
assert inserted["count"] == 1 assert inserted["count"] == 1
@pytest.mark.gui
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/path", str(tmp_db_cfg.path))
@ -174,7 +169,6 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
assert w.editor.highlighter.document() is w.editor.document() assert w.editor.highlighter.document() is w.editor.document()
@pytest.mark.gui
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/path", str(tmp_db_cfg.path))

1773
tests/test_tags.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
import pytest
from PySide6.QtGui import QPalette from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
@ -15,7 +14,6 @@ def test_theme_manager_apply_light_and_dark(app):
assert isinstance(app.palette(), QPalette) assert isinstance(app.palette(), QPalette)
@pytest.mark.gui
def test_theme_manager_system_roundtrip(app, qtbot): def test_theme_manager_system_roundtrip(app, qtbot):
cfg = ThemeConfig(theme=Theme.SYSTEM) cfg = ThemeConfig(theme=Theme.SYSTEM)
mgr = ThemeManager(app, cfg) mgr = ThemeManager(app, cfg)

View file

@ -14,7 +14,6 @@ def editor(app, qtbot):
return ed return ed
@pytest.mark.gui
def test_toolbar_signals_and_styling(qtbot, editor): def test_toolbar_signals_and_styling(qtbot, editor):
host = QWidget() host = QWidget()
qtbot.addWidget(host) qtbot.addWidget(host)

22
vulture_ignorelist.py Normal file
View file

@ -0,0 +1,22 @@
from bouquin.flow_layout import FlowLayout
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.db import DBManager
DBManager.row_factory
FlowLayout.itemAt
FlowLayout.expandingDirections
FlowLayout.hasHeightForWidth
FlowLayout.heightForWidth
MarkdownEditor.apply_weight
MarkdownEditor.apply_italic
MarkdownEditor.apply_strikethrough
MarkdownEditor.apply_code
MarkdownEditor.apply_heading
MarkdownEditor.toggle_bullets
MarkdownEditor.toggle_numbers
MarkdownEditor.toggle_checkboxes
MarkdownHighlighter.highlightBlock