Compare commits
No commits in common. "1becb7900ea27881c5e93440973a3a07a5160d32" and "45525371215830ace8fffb0243d671b5009be7a3" have entirely different histories.
1becb7900e
...
4552537121
26 changed files with 83 additions and 4034 deletions
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
black pyflakes3 vulture
|
||||
black pyflakes3
|
||||
|
||||
- name: Run linters
|
||||
run: |
|
||||
|
|
@ -23,4 +23,3 @@ jobs:
|
|||
black --diff --check tests/*
|
||||
pyflakes3 bouquin/*
|
||||
pyflakes3 tests/*
|
||||
vulture
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
# 0.3
|
||||
# 0.2.1.9
|
||||
|
||||
* Introduce Tags
|
||||
* Make translations dynamically detected from the locales dir rather than hardcoded
|
||||
* Add Italian translations (thanks @mdaleo404)
|
||||
* Fix a few small matters identified with tests
|
||||
* Make locales dynamically detected from the locales dir rather than hardcoded
|
||||
* Add version information in the navigation
|
||||
* 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
|
||||
* Avoid second checkbox/bullet on second newline after first newline
|
||||
* Avoid Home/left arrow jumping to the left side of a list symbol
|
||||
* Various test additions/fixes
|
||||
|
||||
# 0.2.1.8
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ 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.
|
||||
* Images are supported
|
||||
* 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)
|
||||
* Transparent integrity checking of the database when it opens
|
||||
* Automatic locking of the app after a period of inactivity (default 15 min)
|
||||
|
|
@ -38,8 +37,7 @@ 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)
|
||||
* Dark and light themes
|
||||
* Automatically generate checkboxes when typing 'TODO'
|
||||
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
||||
* English, French and Italian locales provided
|
||||
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup
|
||||
|
||||
|
||||
## How to install
|
||||
|
|
|
|||
244
bouquin/db.py
244
bouquin/db.py
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
|
||||
|
|
@ -10,34 +9,9 @@ from pathlib import Path
|
|||
from sqlcipher3 import dbapi2 as sqlite
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
|
||||
from . import strings
|
||||
|
||||
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
|
||||
|
|
@ -108,6 +82,7 @@ class DBManager:
|
|||
# Always keep FKs on
|
||||
cur.execute("PRAGMA foreign_keys = ON;")
|
||||
|
||||
# Create new versioned schema if missing (< 0.1.5)
|
||||
cur.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
|
|
@ -128,24 +103,6 @@ class DBManager:
|
|||
|
||||
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 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()
|
||||
|
|
@ -185,31 +142,22 @@ class DBManager:
|
|||
|
||||
def search_entries(self, text: str) -> list[str]:
|
||||
"""
|
||||
Search for entries by term or tag name.
|
||||
This only works against the latest version of the page.
|
||||
Search for entries by term. This only works against the latest
|
||||
version of the page.
|
||||
"""
|
||||
cur = self.conn.cursor()
|
||||
q = text.strip()
|
||||
pattern = f"%{q.lower()}%"
|
||||
|
||||
pattern = f"%{text}%"
|
||||
rows = cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT p.date, v.content
|
||||
SELECT p.date, v.content
|
||||
FROM pages AS p
|
||||
JOIN versions AS v
|
||||
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) <> ''
|
||||
AND (
|
||||
LOWER(v.content) LIKE ?
|
||||
OR LOWER(COALESCE(t.name, '')) LIKE ?
|
||||
)
|
||||
AND v.content LIKE LOWER(?) ESCAPE '\\'
|
||||
ORDER BY p.date DESC;
|
||||
""",
|
||||
(pattern, pattern),
|
||||
(pattern,),
|
||||
).fetchall()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
|
||||
|
|
@ -438,184 +386,6 @@ class DBManager:
|
|||
except Exception as 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:
|
||||
if self.conn is not None:
|
||||
self.conn.close()
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
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
|
||||
|
|
@ -110,27 +110,5 @@
|
|||
"toolbar_numbered_list": "Numbered list",
|
||||
"toolbar_code_block": "Code block",
|
||||
"toolbar_heading": "Heading",
|
||||
"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"
|
||||
"toolbar_toggle_checkboxes": "Toggle checkboxes"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,27 +110,5 @@
|
|||
"toolbar_numbered_list": "Liste numérotée",
|
||||
"toolbar_code_block": "Bloc de code",
|
||||
"toolbar_heading": "Titre",
|
||||
"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à"
|
||||
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,26 +110,5 @@
|
|||
"toolbar_numbered_list": "Elenco numerato",
|
||||
"toolbar_code_block": "Blocco di codice",
|
||||
"toolbar_heading": "Titolo",
|
||||
"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"
|
||||
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ from .search import Search
|
|||
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||
from .settings_dialog import SettingsDialog
|
||||
from . import strings
|
||||
from .tags_widget import PageTagsWidget
|
||||
from .toolbar import ToolBar
|
||||
from .theme import ThemeManager
|
||||
|
||||
|
|
@ -94,10 +93,6 @@ class MainWindow(QMainWindow):
|
|||
self.search.openDateRequested.connect(self._load_selected_date)
|
||||
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
|
||||
# when the main window is resized.
|
||||
left_panel = QWidget()
|
||||
|
|
@ -105,7 +100,6 @@ class MainWindow(QMainWindow):
|
|||
left_layout.setContentsMargins(8, 8, 8, 8)
|
||||
left_layout.addWidget(self.calendar)
|
||||
left_layout.addWidget(self.search)
|
||||
left_layout.addWidget(self.tags)
|
||||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||
|
||||
# Create tab widget to hold multiple editors
|
||||
|
|
@ -209,10 +203,6 @@ class MainWindow(QMainWindow):
|
|||
act_backup.setShortcut("Ctrl+Shift+B")
|
||||
act_backup.triggered.connect(self._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()
|
||||
act_quit = QAction("&" + strings._("quit"), self)
|
||||
act_quit.setShortcut("Ctrl+Q")
|
||||
|
|
@ -508,10 +498,6 @@ class MainWindow(QMainWindow):
|
|||
with QSignalBlocker(self.calendar):
|
||||
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
|
||||
self._sync_toolbar()
|
||||
|
||||
|
|
@ -655,9 +641,6 @@ class MainWindow(QMainWindow):
|
|||
# Keep tabs sorted 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):
|
||||
"""Load a specific date's content into a given editor."""
|
||||
date_iso = date.toString("yyyy-MM-dd")
|
||||
|
|
@ -815,10 +798,6 @@ class MainWindow(QMainWindow):
|
|||
if current_index >= 0:
|
||||
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
|
||||
self._reorder_tabs_by_date()
|
||||
|
||||
|
|
@ -1035,59 +1014,6 @@ class MainWindow(QMainWindow):
|
|||
for path_str in paths:
|
||||
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 ------------#
|
||||
def _open_settings(self):
|
||||
dlg = SettingsDialog(self.cfg, self.db, self)
|
||||
|
|
|
|||
|
|
@ -73,10 +73,9 @@ class MarkdownEditor(QTextEdit):
|
|||
|
||||
def setDocument(self, doc):
|
||||
super().setDocument(doc)
|
||||
# Recreate the highlighter for the new document
|
||||
# (the old one gets deleted with the old document)
|
||||
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
|
||||
self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager)
|
||||
# reattach the highlighter to the new document
|
||||
if hasattr(self, "highlighter") and self.highlighter:
|
||||
self.highlighter.setDocument(self.document())
|
||||
self._apply_line_spacing()
|
||||
self._apply_code_block_spacing()
|
||||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||||
|
|
@ -98,7 +97,7 @@ class MarkdownEditor(QTextEdit):
|
|||
line = block.text()
|
||||
pos_in_block = c.position() - block.position()
|
||||
|
||||
# Transform markdown checkboxes and 'TODO' to unicode checkboxes
|
||||
# Transform markldown checkboxes and 'TODO' to unicode checkboxes
|
||||
def transform_line(s: str) -> str:
|
||||
s = s.replace(
|
||||
f"- {self._CHECK_CHECKED_STORAGE} ",
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
|||
self.setFormat(end - 2, 2, self.syntax_format)
|
||||
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
||||
|
||||
# --- Italic (*) or (_): skip if it overlaps any triple
|
||||
# --- Italic (*) or (_): skip if it overlaps any triple, keep your guards
|
||||
for m in re.finditer(
|
||||
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
# 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()
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
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)
|
||||
|
|
@ -204,7 +204,7 @@ class ThemeManager(QObject):
|
|||
)
|
||||
|
||||
if is_dark:
|
||||
# Use the link color as the accent
|
||||
# Use the link color as the accent (you set this to ORANGE in dark palette)
|
||||
accent = pal.color(QPalette.Link)
|
||||
r, g, b = accent.red(), accent.green(), accent.blue()
|
||||
accent_hex = accent.name()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "bouquin"
|
||||
version = "0.3"
|
||||
version = "0.2.1.8"
|
||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
readme = "README.md"
|
||||
|
|
@ -31,9 +31,6 @@ pyproject-appimage = "^4.2"
|
|||
script = "bouquin"
|
||||
output = "Bouquin.AppImage"
|
||||
|
||||
[tool.vulture]
|
||||
paths = ["bouquin", "vulture_ignorelist.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ def editor(app, qtbot):
|
|||
return ed
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_findbar_basic_navigation(qtbot, editor):
|
||||
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
|
||||
editor.moveCursor(QTextCursor.Start)
|
||||
|
|
@ -112,6 +113,7 @@ def test_update_highlight_clear_when_empty(qtbot, editor):
|
|||
assert not editor.extraSelections()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_maybe_hide_and_wrap_prev(qtbot, editor):
|
||||
editor.setPlainText("a a a")
|
||||
fb = FindBar(editor=editor, shortcut_parent=editor)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import pytest
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.lock_overlay import LockOverlay
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_lock_overlay_reacts_to_theme(app, qtbot):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,11 @@ from bouquin.settings import get_settings
|
|||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect
|
||||
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox, QDialog
|
||||
from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox
|
||||
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):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
|
|
@ -80,6 +79,7 @@ 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
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
|
|
@ -113,6 +113,7 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch)
|
|||
assert called["docs"] and called["bugs"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
# Seed some content
|
||||
fresh_db.save_new_version("2001-01-01", "alpha", "n1")
|
||||
|
|
@ -189,6 +190,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|||
assert errs["hit"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
|
@ -246,6 +248,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|||
assert str(dest.with_suffix(".db")) in hit["text"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
from bouquin.settings import get_settings
|
||||
|
|
@ -280,6 +283,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
|
|||
monkeypatch.delattr(w, "_save_editor_content", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
|
|
@ -325,6 +329,7 @@ def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
|
|||
assert called["max"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
|
||||
# Seed DB so refresh marks does something
|
||||
fresh_db.save_new_version("2021-08-15", "note", "")
|
||||
|
|
@ -397,6 +402,7 @@ def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch
|
|||
w._show_calendar_context_menu(cal_pos)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_event_filter_keypress_starts_idle_timer(qtbot, app):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
|
|
@ -435,7 +441,7 @@ def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path):
|
|||
# Avoid accidentaly creating DB by short-circuiting the prompt loop
|
||||
class MW(MainWindow):
|
||||
def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802
|
||||
assert first_time is True
|
||||
assert first_time is True # hit line 73 path
|
||||
return False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
|
|
@ -938,7 +944,7 @@ def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch
|
|||
|
||||
# remove timer to hit early return
|
||||
delattr(w, "_idle_timer")
|
||||
w._apply_idle_minutes(5) # no crash
|
||||
w._apply_idle_minutes(5) # no crash => line 1176 branch
|
||||
|
||||
# re-create a timer and simulate locking then disabling idle
|
||||
w._idle_timer = QTimer(w)
|
||||
|
|
@ -1021,6 +1027,7 @@ def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch):
|
|||
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):
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
qtbot.addWidget(w)
|
||||
|
|
@ -1096,6 +1103,7 @@ def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch
|
|||
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):
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
qtbot.addWidget(w)
|
||||
|
|
@ -1116,6 +1124,7 @@ def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatc
|
|||
assert w.tab_widget.count() == before
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_export_cancel_then_empty_filename(
|
||||
qtbot, app, tmp_db_cfg, monkeypatch, tmp_path
|
||||
):
|
||||
|
|
@ -1178,6 +1187,7 @@ def test_export_cancel_then_empty_filename(
|
|||
w._export() # returns early at filename check
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_set_editor_markdown_preserve_view_preserves(
|
||||
qtbot, app, tmp_db_cfg, monkeypatch
|
||||
):
|
||||
|
|
@ -1202,6 +1212,7 @@ def test_set_editor_markdown_preserve_view_preserves(
|
|||
assert w.editor.to_markdown().endswith("extra\n")
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_load_date_into_editor_with_extra_data_forces_save(
|
||||
qtbot, app, tmp_db_cfg, monkeypatch
|
||||
):
|
||||
|
|
@ -1219,6 +1230,7 @@ def test_load_date_into_editor_with_extra_data_forces_save(
|
|||
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):
|
||||
"""Covers moveTab for both dated and undated buckets."""
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
|
|
@ -1312,6 +1324,7 @@ 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
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch):
|
||||
"""Covers DB not connected branch."""
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
|
|
@ -1324,6 +1337,7 @@ def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypa
|
|||
w._save_editor_content(w.editor)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
|
||||
qtbot, app, tmp_db_cfg, monkeypatch
|
||||
):
|
||||
|
|
@ -1356,6 +1370,7 @@ def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
|
|||
assert saved["iso"] == "2024-01-01"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
|
||||
"""Covers early return when toolbar is already bound."""
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
|
|
@ -1365,6 +1380,7 @@ def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
|
|||
w._bind_toolbar()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch):
|
||||
"""Covers the early return when user selects no files."""
|
||||
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
|
||||
|
|
@ -1404,6 +1420,7 @@ def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeyp
|
|||
assert hit["start"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch):
|
||||
"""
|
||||
Covers exception swallowing around settings writes & ensures close proceeds
|
||||
|
|
@ -1471,328 +1488,3 @@ def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch):
|
|||
w.closeEvent(ev)
|
||||
|
||||
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
|
|
@ -1,3 +1,4 @@
|
|||
import pytest
|
||||
from bouquin.search import Search
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ def test_make_html_snippet_variants(qtbot, fresh_db):
|
|||
assert "<b>delta</b>" in frag
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
|
|
@ -90,6 +92,7 @@ def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
|
|||
assert frag == "" and not left and not right
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
import bouquin.settings_dialog as sd
|
||||
|
|
@ -8,6 +10,7 @@ from PySide6.QtCore import QTimer
|
|||
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):
|
||||
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||
app = QApplication.instance()
|
||||
|
|
@ -203,6 +206,7 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
|
|||
assert called["text"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import types
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ from bouquin.main_window import MainWindow
|
|||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
# point to the temp encrypted DB
|
||||
s = get_settings()
|
||||
|
|
@ -41,6 +43,7 @@ def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
assert w.tab_widget.currentWidget().current_date == date1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toolbar_signals_dispatch_once_per_click(
|
||||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
|
||||
):
|
||||
|
|
@ -112,6 +115,7 @@ def test_toolbar_signals_dispatch_once_per_click(
|
|||
assert calls2["bold"] == 1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_history_and_insert_image_not_duplicated(
|
||||
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
|
||||
):
|
||||
|
|
@ -154,6 +158,7 @@ def test_history_and_insert_image_not_duplicated(
|
|||
assert inserted["count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
|
|
@ -169,6 +174,7 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
|
|||
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):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
|
|
|
|||
1773
tests/test_tags.py
1773
tests/test_tags.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ def test_theme_manager_apply_light_and_dark(app):
|
|||
assert isinstance(app.palette(), QPalette)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_theme_manager_system_roundtrip(app, qtbot):
|
||||
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
||||
mgr = ThemeManager(app, cfg)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ def editor(app, qtbot):
|
|||
return ed
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toolbar_signals_and_styling(qtbot, editor):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue