Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3f5fcf3a | |||
| e146a92b31 | |||
| 53e99af912 | |||
| 72862f9a4f | |||
| e0d7826fe0 | |||
| 50fee4ec78 | |||
| cc9453997e | |||
| 3db384e7e4 | |||
| f778afd268 | |||
| 0caf0efeef |
13 changed files with 564 additions and 148 deletions
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 0.1.2
|
||||||
|
|
||||||
|
* Switch from Markdown to HTML via QTextEdit, with a toolbar
|
||||||
|
* Add search ability
|
||||||
|
* Fix Settings shortcut and change nav menu from 'File' to 'Application'
|
||||||
|
|
||||||
|
# 0.1.1
|
||||||
|
|
||||||
|
* Add ability to change the key
|
||||||
|
* Add ability to jump to today's date
|
||||||
|
* Add shortcut for Settings (Ctrl+E) so as not to collide with Ctrl+S (Save)
|
||||||
|
|
||||||
|
# 0.1.0
|
||||||
|
|
||||||
|
* Initial release.
|
||||||
10
README.md
10
README.md
|
|
@ -20,18 +20,16 @@ There is deliberately no network connectivity or syncing intended.
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Every 'page' is linked to the calendar day
|
* Every 'page' is linked to the calendar day
|
||||||
* Basic markdown
|
* Text is HTML with basic styling
|
||||||
|
* Search
|
||||||
* Automatic periodic saving (or explicitly save)
|
* Automatic periodic saving (or explicitly save)
|
||||||
* Navigating from one day to the next automatically saves
|
|
||||||
* Basic keyboard shortcuts
|
|
||||||
* Transparent integrity checking of the database when it opens
|
* Transparent integrity checking of the database when it opens
|
||||||
|
* Rekey the database (change the password)
|
||||||
|
|
||||||
|
|
||||||
## Yet to do
|
## Yet to do
|
||||||
|
|
||||||
* Search
|
|
||||||
* Taxonomy/tagging
|
* Taxonomy/tagging
|
||||||
* Ability to change the SQLCipher key
|
|
||||||
* Export to other formats (plaintext, json, sql etc)
|
* Export to other formats (plaintext, json, sql etc)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,7 +46,7 @@ There is deliberately no network connectivity or syncing intended.
|
||||||
|
|
||||||
* Download the whl and run it
|
* Download the whl and run it
|
||||||
|
|
||||||
### From PyPi
|
### From PyPi/pip
|
||||||
|
|
||||||
* `pip install bouquin`
|
* `pip install bouquin`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,25 @@ class DBManager:
|
||||||
cur.execute("PRAGMA user_version = 1;")
|
cur.execute("PRAGMA user_version = 1;")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def rekey(self, new_key: str) -> None:
|
||||||
|
"""
|
||||||
|
Change the SQLCipher passphrase in-place, then reopen the connection
|
||||||
|
with the new key to verify.
|
||||||
|
"""
|
||||||
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
# Change the encryption key of the currently open database
|
||||||
|
cur.execute(f"PRAGMA rekey = '{new_key}';")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# Close and reopen with the new key to verify and restore PRAGMAs
|
||||||
|
self.conn.close()
|
||||||
|
self.conn = None
|
||||||
|
self.cfg.key = new_key
|
||||||
|
if not self.connect():
|
||||||
|
raise sqlite.Error("Re-open failed after rekey")
|
||||||
|
|
||||||
def get_entry(self, date_iso: str) -> str:
|
def get_entry(self, date_iso: str) -> str:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
|
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
|
||||||
|
|
@ -81,6 +100,12 @@ class DBManager:
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def search_entries(self, text: str) -> list[str]:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
pattern = f"%{text}%"
|
||||||
|
cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
|
||||||
|
return [r for r in cur.fetchall()]
|
||||||
|
|
||||||
def dates_with_content(self) -> list[str]:
|
def dates_with_content(self) -> list[str]:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
||||||
|
|
|
||||||
120
bouquin/editor.py
Normal file
120
bouquin/editor.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
|
QTextCharFormat,
|
||||||
|
QTextListFormat,
|
||||||
|
QTextBlockFormat,
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Slot
|
||||||
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
|
||||||
|
class Editor(QTextEdit):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
|
self.setTabStopDistance(tab_w)
|
||||||
|
|
||||||
|
def merge_on_sel(self, fmt):
|
||||||
|
"""
|
||||||
|
Sets the styling on the selected characters.
|
||||||
|
"""
|
||||||
|
cursor = self.textCursor()
|
||||||
|
if not cursor.hasSelection():
|
||||||
|
cursor.select(cursor.SelectionType.WordUnderCursor)
|
||||||
|
cursor.mergeCharFormat(fmt)
|
||||||
|
self.mergeCurrentCharFormat(fmt)
|
||||||
|
|
||||||
|
@Slot(QFont.Weight)
|
||||||
|
def apply_weight(self, weight):
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setFontWeight(weight)
|
||||||
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def apply_italic(self):
|
||||||
|
cur = self.currentCharFormat()
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setFontItalic(not cur.fontItalic())
|
||||||
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def apply_underline(self):
|
||||||
|
cur = self.currentCharFormat()
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setFontUnderline(not cur.fontUnderline())
|
||||||
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def apply_strikethrough(self):
|
||||||
|
cur = self.currentCharFormat()
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setFontStrikeOut(not cur.fontStrikeOut())
|
||||||
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def apply_code(self):
|
||||||
|
c = self.textCursor()
|
||||||
|
if not c.hasSelection():
|
||||||
|
c.select(c.SelectionType.BlockUnderCursor)
|
||||||
|
|
||||||
|
bf = QTextBlockFormat()
|
||||||
|
bf.setLeftMargin(12)
|
||||||
|
bf.setRightMargin(12)
|
||||||
|
bf.setTopMargin(6)
|
||||||
|
bf.setBottomMargin(6)
|
||||||
|
bf.setBackground(QColor(245, 245, 245))
|
||||||
|
bf.setNonBreakableLines(True)
|
||||||
|
|
||||||
|
cf = QTextCharFormat()
|
||||||
|
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
||||||
|
cf.setFont(mono)
|
||||||
|
cf.setFontFixedPitch(True)
|
||||||
|
|
||||||
|
# If the current block already looks like a code block, remove styling
|
||||||
|
cur_bf = c.blockFormat()
|
||||||
|
is_code = (
|
||||||
|
cur_bf.nonBreakableLines()
|
||||||
|
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
|
||||||
|
)
|
||||||
|
if is_code:
|
||||||
|
# clear: margins/background/wrapping
|
||||||
|
bf = QTextBlockFormat()
|
||||||
|
cf = QTextCharFormat()
|
||||||
|
|
||||||
|
c.mergeBlockFormat(bf)
|
||||||
|
c.mergeBlockCharFormat(cf)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def apply_heading(self, size):
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
if size:
|
||||||
|
fmt.setFontWeight(QFont.Weight.Bold)
|
||||||
|
fmt.setFontPointSize(size)
|
||||||
|
else:
|
||||||
|
fmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
fmt.setFontPointSize(self.font().pointSizeF())
|
||||||
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
def toggle_bullets(self):
|
||||||
|
c = self.textCursor()
|
||||||
|
lst = c.currentList()
|
||||||
|
if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
|
||||||
|
lst.remove(c.block())
|
||||||
|
return
|
||||||
|
fmt = QTextListFormat()
|
||||||
|
fmt.setStyle(QTextListFormat.Style.ListDisc)
|
||||||
|
c.createList(fmt)
|
||||||
|
|
||||||
|
def toggle_numbers(self):
|
||||||
|
c = self.textCursor()
|
||||||
|
lst = c.currentList()
|
||||||
|
if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
|
||||||
|
lst.remove(c.block())
|
||||||
|
return
|
||||||
|
fmt = QTextListFormat()
|
||||||
|
fmt.setStyle(QTextListFormat.Style.ListDecimal)
|
||||||
|
c.createList(fmt)
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
|
||||||
ST_NORMAL = 0
|
|
||||||
ST_CODE = 1
|
|
||||||
|
|
||||||
FENCE = re.compile(r"^```")
|
|
||||||
|
|
||||||
def __init__(self, document):
|
|
||||||
super().__init__(document)
|
|
||||||
|
|
||||||
base_size = document.defaultFont().pointSizeF() or 12.0
|
|
||||||
|
|
||||||
# Monospace for code
|
|
||||||
self.mono = QFont("Monospace")
|
|
||||||
self.mono.setStyleHint(QFont.TypeWriter)
|
|
||||||
|
|
||||||
# Light, high-contrast scheme for code
|
|
||||||
self.col_bg = QColor("#eef2f6") # light code bg
|
|
||||||
self.col_fg = QColor("#1f2328") # dark text
|
|
||||||
|
|
||||||
# Formats
|
|
||||||
self.fmt_h = [QTextCharFormat() for _ in range(6)]
|
|
||||||
for i, f in enumerate(self.fmt_h, start=1):
|
|
||||||
f.setFontWeight(QFont.Weight.Bold)
|
|
||||||
f.setFontPointSize(base_size + (7 - i))
|
|
||||||
self.fmt_bold = QTextCharFormat()
|
|
||||||
self.fmt_bold.setFontWeight(QFont.Weight.Bold)
|
|
||||||
self.fmt_italic = QTextCharFormat()
|
|
||||||
self.fmt_italic.setFontItalic(True)
|
|
||||||
self.fmt_quote = QTextCharFormat()
|
|
||||||
self.fmt_quote.setForeground(QColor("#6a737d"))
|
|
||||||
self.fmt_link = QTextCharFormat()
|
|
||||||
self.fmt_link.setFontUnderline(True)
|
|
||||||
self.fmt_list = QTextCharFormat()
|
|
||||||
self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
|
|
||||||
self.fmt_strike = QTextCharFormat()
|
|
||||||
self.fmt_strike.setFontStrikeOut(True)
|
|
||||||
|
|
||||||
# Uniform code style
|
|
||||||
self.fmt_code = QTextCharFormat()
|
|
||||||
self.fmt_code.setFont(self.mono)
|
|
||||||
self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
|
|
||||||
self.fmt_code.setBackground(self.col_bg)
|
|
||||||
self.fmt_code.setForeground(self.col_fg)
|
|
||||||
|
|
||||||
# Simple patterns
|
|
||||||
self.re_heading = re.compile(r"^(#{1,6}) +.*$")
|
|
||||||
self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
|
|
||||||
self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
|
|
||||||
self.re_strike = re.compile(r"~~(.+?)~~")
|
|
||||||
self.re_inline_code = re.compile(r"`([^`]+)`")
|
|
||||||
self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
||||||
self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
|
|
||||||
self.re_quote = re.compile(r"^> ?.*$")
|
|
||||||
|
|
||||||
def highlightBlock(self, text: str) -> None:
|
|
||||||
prev = self.previousBlockState()
|
|
||||||
in_code = prev == self.ST_CODE
|
|
||||||
|
|
||||||
if in_code:
|
|
||||||
# Entire line is code
|
|
||||||
self.setFormat(0, len(text), self.fmt_code)
|
|
||||||
if self.FENCE.match(text):
|
|
||||||
self.setCurrentBlockState(self.ST_NORMAL)
|
|
||||||
else:
|
|
||||||
self.setCurrentBlockState(self.ST_CODE)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Starting/ending a fenced block?
|
|
||||||
if self.FENCE.match(text):
|
|
||||||
self.setFormat(0, len(text), self.fmt_code)
|
|
||||||
self.setCurrentBlockState(self.ST_CODE)
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- Normal markdown styling ---
|
|
||||||
m = self.re_heading.match(text)
|
|
||||||
if m:
|
|
||||||
level = min(len(m.group(1)), 6)
|
|
||||||
self.setFormat(0, len(text), self.fmt_h[level - 1])
|
|
||||||
self.setCurrentBlockState(self.ST_NORMAL)
|
|
||||||
return
|
|
||||||
|
|
||||||
m = self.re_list.match(text)
|
|
||||||
if m:
|
|
||||||
self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
|
|
||||||
|
|
||||||
if self.re_quote.match(text):
|
|
||||||
self.setFormat(0, len(text), self.fmt_quote)
|
|
||||||
|
|
||||||
for m in self.re_inline_code.finditer(text):
|
|
||||||
self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
|
|
||||||
|
|
||||||
for m in self.re_bold.finditer(text):
|
|
||||||
self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
|
|
||||||
|
|
||||||
for m in self.re_italic.finditer(text):
|
|
||||||
self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
|
|
||||||
|
|
||||||
for m in self.re_strike.finditer(text):
|
|
||||||
self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
|
|
||||||
|
|
||||||
for m in self.re_link.finditer(text):
|
|
||||||
start = m.start(1) - 1
|
|
||||||
length = len(m.group(1)) + 2
|
|
||||||
self.setFormat(start, length, self.fmt_link)
|
|
||||||
|
|
||||||
self.setCurrentBlockState(self.ST_NORMAL)
|
|
||||||
|
|
@ -11,5 +11,6 @@ def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName(APP_NAME)
|
app.setApplicationName(APP_NAME)
|
||||||
app.setOrganizationName(APP_ORG)
|
app.setOrganizationName(APP_ORG)
|
||||||
win = MainWindow(); win.show()
|
win = MainWindow()
|
||||||
|
win.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,29 @@ from __future__ import annotations
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, QTimer, Qt
|
from PySide6.QtCore import QDate, QTimer, Qt
|
||||||
from PySide6.QtGui import QAction, QFont, QTextCharFormat
|
from PySide6.QtGui import (
|
||||||
|
QAction,
|
||||||
|
QFont,
|
||||||
|
QTextCharFormat,
|
||||||
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
|
QDialog,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
QSizePolicy,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .settings import APP_NAME, load_db_config, save_db_config
|
from .editor import Editor
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
from .highlighter import MarkdownHighlighter
|
from .search import Search
|
||||||
|
from .settings import APP_NAME, load_db_config, save_db_config
|
||||||
from .settings_dialog import SettingsDialog
|
from .settings_dialog import SettingsDialog
|
||||||
|
from .toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
|
@ -40,17 +45,35 @@ class MainWindow(QMainWindow):
|
||||||
self.calendar.setGridVisible(True)
|
self.calendar.setGridVisible(True)
|
||||||
self.calendar.selectionChanged.connect(self._on_date_changed)
|
self.calendar.selectionChanged.connect(self._on_date_changed)
|
||||||
|
|
||||||
|
self.search = Search(self.db)
|
||||||
|
self.search.openDateRequested.connect(self._load_selected_date)
|
||||||
|
|
||||||
|
# Lock the calendar to the left panel at the top to stop it stretching
|
||||||
|
# when the main window is resized.
|
||||||
left_panel = QWidget()
|
left_panel = QWidget()
|
||||||
left_layout = QVBoxLayout(left_panel)
|
left_layout = QVBoxLayout(left_panel)
|
||||||
left_layout.setContentsMargins(8, 8, 8, 8)
|
left_layout.setContentsMargins(8, 8, 8, 8)
|
||||||
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
|
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
|
||||||
|
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
|
||||||
left_layout.addStretch(1)
|
left_layout.addStretch(1)
|
||||||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||||
|
|
||||||
self.editor = QPlainTextEdit()
|
# This is the note-taking editor
|
||||||
tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ")
|
self.editor = Editor()
|
||||||
self.editor.setTabStopDistance(tab_w)
|
|
||||||
self.highlighter = MarkdownHighlighter(self.editor.document())
|
# Toolbar for controlling styling
|
||||||
|
tb = ToolBar()
|
||||||
|
self.addToolBar(tb)
|
||||||
|
# Wire toolbar intents to editor methods
|
||||||
|
tb.boldRequested.connect(self.editor.apply_weight)
|
||||||
|
tb.italicRequested.connect(self.editor.apply_italic)
|
||||||
|
tb.underlineRequested.connect(self.editor.apply_underline)
|
||||||
|
tb.strikeRequested.connect(self.editor.apply_strikethrough)
|
||||||
|
tb.codeRequested.connect(self.editor.apply_code)
|
||||||
|
tb.headingRequested.connect(self.editor.apply_heading)
|
||||||
|
tb.bulletsRequested.connect(self.editor.toggle_bullets)
|
||||||
|
tb.numbersRequested.connect(self.editor.toggle_numbers)
|
||||||
|
tb.alignRequested.connect(self.editor.setAlignment)
|
||||||
|
|
||||||
split = QSplitter()
|
split = QSplitter()
|
||||||
split.addWidget(left_panel)
|
split.addWidget(left_panel)
|
||||||
|
|
@ -67,12 +90,13 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Menu bar (File)
|
# Menu bar (File)
|
||||||
mb = self.menuBar()
|
mb = self.menuBar()
|
||||||
file_menu = mb.addMenu("&File")
|
file_menu = mb.addMenu("&Application")
|
||||||
act_save = QAction("&Save", self)
|
act_save = QAction("&Save", self)
|
||||||
act_save.setShortcut("Ctrl+S")
|
act_save.setShortcut("Ctrl+S")
|
||||||
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
||||||
file_menu.addAction(act_save)
|
file_menu.addAction(act_save)
|
||||||
act_settings = QAction("&Settings", self)
|
act_settings = QAction("S&ettings", self)
|
||||||
|
act_settings.setShortcut("Ctrl+E")
|
||||||
act_settings.triggered.connect(self._open_settings)
|
act_settings.triggered.connect(self._open_settings)
|
||||||
file_menu.addAction(act_settings)
|
file_menu.addAction(act_settings)
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
|
|
@ -81,7 +105,7 @@ class MainWindow(QMainWindow):
|
||||||
act_quit.triggered.connect(self.close)
|
act_quit.triggered.connect(self.close)
|
||||||
file_menu.addAction(act_quit)
|
file_menu.addAction(act_quit)
|
||||||
|
|
||||||
# Navigate menu with next/previous day
|
# Navigate menu with next/previous/today
|
||||||
nav_menu = mb.addMenu("&Navigate")
|
nav_menu = mb.addMenu("&Navigate")
|
||||||
act_prev = QAction("Previous Day", self)
|
act_prev = QAction("Previous Day", self)
|
||||||
act_prev.setShortcut("Ctrl+P")
|
act_prev.setShortcut("Ctrl+P")
|
||||||
|
|
@ -97,6 +121,13 @@ class MainWindow(QMainWindow):
|
||||||
nav_menu.addAction(act_next)
|
nav_menu.addAction(act_next)
|
||||||
self.addAction(act_next)
|
self.addAction(act_next)
|
||||||
|
|
||||||
|
act_today = QAction("Today", self)
|
||||||
|
act_today.setShortcut("Ctrl+T")
|
||||||
|
act_today.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
act_today.triggered.connect(self._adjust_today)
|
||||||
|
nav_menu.addAction(act_today)
|
||||||
|
self.addAction(act_today)
|
||||||
|
|
||||||
# Autosave
|
# Autosave
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
self._save_timer = QTimer(self)
|
self._save_timer = QTimer(self)
|
||||||
|
|
@ -104,12 +135,14 @@ class MainWindow(QMainWindow):
|
||||||
self._save_timer.timeout.connect(self._save_current)
|
self._save_timer.timeout.connect(self._save_current)
|
||||||
self.editor.textChanged.connect(self._on_text_changed)
|
self.editor.textChanged.connect(self._on_text_changed)
|
||||||
|
|
||||||
# First load + mark dates with content
|
# First load + mark dates in calendar with content
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
# --- DB lifecycle
|
|
||||||
def _try_connect(self) -> bool:
|
def _try_connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Try to connect to the database.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.db = DBManager(self.cfg)
|
self.db = DBManager(self.cfg)
|
||||||
ok = self.db.connect()
|
ok = self.db.connect()
|
||||||
|
|
@ -123,6 +156,9 @@ class MainWindow(QMainWindow):
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
def _prompt_for_key_until_valid(self) -> bool:
|
def _prompt_for_key_until_valid(self) -> bool:
|
||||||
|
"""
|
||||||
|
Prompt for the SQLCipher key.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
||||||
if dlg.exec() != QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
|
@ -131,8 +167,11 @@ class MainWindow(QMainWindow):
|
||||||
if self._try_connect():
|
if self._try_connect():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# --- Calendar marks to indicate text exists for htat day -----------------
|
|
||||||
def _refresh_calendar_marks(self):
|
def _refresh_calendar_marks(self):
|
||||||
|
"""
|
||||||
|
Sets a bold marker on the day to indicate that text exists
|
||||||
|
for that day.
|
||||||
|
"""
|
||||||
fmt_bold = QTextCharFormat()
|
fmt_bold = QTextCharFormat()
|
||||||
fmt_bold.setFontWeight(QFont.Weight.Bold)
|
fmt_bold.setFontWeight(QFont.Weight.Bold)
|
||||||
# Clear previous marks
|
# Clear previous marks
|
||||||
|
|
@ -153,15 +192,16 @@ class MainWindow(QMainWindow):
|
||||||
d = self.calendar.selectedDate()
|
d = self.calendar.selectedDate()
|
||||||
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
||||||
|
|
||||||
def _load_selected_date(self):
|
def _load_selected_date(self, date_iso=False):
|
||||||
date_iso = self._current_date_iso()
|
if not date_iso:
|
||||||
|
date_iso = self._current_date_iso()
|
||||||
try:
|
try:
|
||||||
text = self.db.get_entry(date_iso)
|
text = self.db.get_entry(date_iso)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Read Error", str(e))
|
QMessageBox.critical(self, "Read Error", str(e))
|
||||||
return
|
return
|
||||||
self.editor.blockSignals(True)
|
self.editor.blockSignals(True)
|
||||||
self.editor.setPlainText(text)
|
self.editor.setHtml(text)
|
||||||
self.editor.blockSignals(False)
|
self.editor.blockSignals(False)
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
# track which date the editor currently represents
|
# track which date the editor currently represents
|
||||||
|
|
@ -176,6 +216,11 @@ class MainWindow(QMainWindow):
|
||||||
d = self.calendar.selectedDate().addDays(delta)
|
d = self.calendar.selectedDate().addDays(delta)
|
||||||
self.calendar.setSelectedDate(d)
|
self.calendar.setSelectedDate(d)
|
||||||
|
|
||||||
|
def _adjust_today(self):
|
||||||
|
"""Jump to today."""
|
||||||
|
today = QDate.currentDate()
|
||||||
|
self.calendar.setSelectedDate(today)
|
||||||
|
|
||||||
def _on_date_changed(self):
|
def _on_date_changed(self):
|
||||||
"""
|
"""
|
||||||
When the calendar selection changes, save the previous day's note if dirty,
|
When the calendar selection changes, save the previous day's note if dirty,
|
||||||
|
|
@ -199,7 +244,7 @@ class MainWindow(QMainWindow):
|
||||||
"""
|
"""
|
||||||
if not self._dirty and not explicit:
|
if not self._dirty and not explicit:
|
||||||
return
|
return
|
||||||
text = self.editor.toPlainText()
|
text = self.editor.toHtml()
|
||||||
try:
|
try:
|
||||||
self.db.upsert_entry(date_iso, text)
|
self.db.upsert_entry(date_iso, text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -219,7 +264,7 @@ class MainWindow(QMainWindow):
|
||||||
self._save_date(self._current_date_iso(), explicit)
|
self._save_date(self._current_date_iso(), explicit)
|
||||||
|
|
||||||
def _open_settings(self):
|
def _open_settings(self):
|
||||||
dlg = SettingsDialog(self.cfg, self)
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
||||||
if dlg.exec() == QDialog.Accepted:
|
if dlg.exec() == QDialog.Accepted:
|
||||||
new_cfg = dlg.config
|
new_cfg = dlg.config
|
||||||
if new_cfg.path != self.cfg.path:
|
if new_cfg.path != self.cfg.path:
|
||||||
|
|
@ -236,7 +281,7 @@ class MainWindow(QMainWindow):
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
def closeEvent(self, event): # noqa: N802
|
def closeEvent(self, event):
|
||||||
try:
|
try:
|
||||||
self._save_current()
|
self._save_current()
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
|
|
||||||
195
bouquin/search.py
Normal file
195
bouquin/search.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable, Tuple
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QHBoxLayout,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
# type: rows are (date_iso, content)
|
||||||
|
Row = Tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class Search(QWidget):
|
||||||
|
"""Encapsulates the search UI + logic and emits a signal when a result is chosen."""
|
||||||
|
|
||||||
|
openDateRequested = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, db, parent: QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
self.search = QLineEdit()
|
||||||
|
self.search.setPlaceholderText("Search for notes here")
|
||||||
|
self.search.textChanged.connect(self._search)
|
||||||
|
|
||||||
|
self.results = QListWidget()
|
||||||
|
self.results.setUniformItemSizes(False)
|
||||||
|
self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
|
||||||
|
self.results.itemClicked.connect(self._open_selected)
|
||||||
|
self.results.hide()
|
||||||
|
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.setContentsMargins(0, 0, 0, 0)
|
||||||
|
lay.setSpacing(6)
|
||||||
|
lay.addWidget(self.search)
|
||||||
|
lay.addWidget(self.results)
|
||||||
|
|
||||||
|
def _open_selected(self, item: QListWidgetItem):
|
||||||
|
date_str = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if date_str:
|
||||||
|
self.openDateRequested.emit(date_str)
|
||||||
|
|
||||||
|
def _search(self, text: str):
|
||||||
|
"""
|
||||||
|
Search for the supplied text in the database.
|
||||||
|
For all rows found, populate the results widget with a clickable preview.
|
||||||
|
"""
|
||||||
|
q = text.strip()
|
||||||
|
if not q:
|
||||||
|
self.results.clear()
|
||||||
|
self.results.hide()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows: Iterable[Row] = self._db.search_entries(q)
|
||||||
|
except Exception:
|
||||||
|
# be quiet on DB errors here; caller can surface if desired
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
self._populate_results(q, rows)
|
||||||
|
|
||||||
|
def _populate_results(self, query: str, rows: Iterable[Row]):
|
||||||
|
self.results.clear()
|
||||||
|
rows = list(rows)
|
||||||
|
if not rows:
|
||||||
|
self.results.hide()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.results.show()
|
||||||
|
|
||||||
|
for date_str, content in rows:
|
||||||
|
# Build an HTML fragment around the match and whether to show ellipses
|
||||||
|
frag_html, left_ell, right_ell = self._make_html_snippet(
|
||||||
|
content, query, radius=60, maxlen=180
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||||
|
container = QWidget()
|
||||||
|
outer = QVBoxLayout(container)
|
||||||
|
outer.setContentsMargins(8, 6, 8, 6)
|
||||||
|
outer.setSpacing(2)
|
||||||
|
|
||||||
|
# Date label (plain text)
|
||||||
|
date_lbl = QLabel(date_str)
|
||||||
|
date_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
date_f = date_lbl.font()
|
||||||
|
date_f.setPointSizeF(date_f.pointSizeF() - 1)
|
||||||
|
date_lbl.setFont(date_f)
|
||||||
|
date_lbl.setStyleSheet("color:#666;")
|
||||||
|
outer.addWidget(date_lbl)
|
||||||
|
|
||||||
|
# Preview row with optional ellipses
|
||||||
|
row = QWidget()
|
||||||
|
h = QHBoxLayout(row)
|
||||||
|
h.setContentsMargins(0, 0, 0, 0)
|
||||||
|
h.setSpacing(4)
|
||||||
|
|
||||||
|
if left_ell:
|
||||||
|
left = QLabel("…")
|
||||||
|
left.setStyleSheet("color:#888;")
|
||||||
|
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
preview = QLabel()
|
||||||
|
preview.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
preview.setWordWrap(True)
|
||||||
|
preview.setOpenExternalLinks(True) # keep links in your HTML clickable
|
||||||
|
preview.setText(
|
||||||
|
frag_html
|
||||||
|
if frag_html
|
||||||
|
else "<span style='color:#888'>(no preview)</span>"
|
||||||
|
)
|
||||||
|
h.addWidget(preview, 1)
|
||||||
|
|
||||||
|
if right_ell:
|
||||||
|
right = QLabel("…")
|
||||||
|
right.setStyleSheet("color:#888;")
|
||||||
|
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
|
||||||
|
|
||||||
|
outer.addWidget(row)
|
||||||
|
|
||||||
|
# ---- Add to list ----
|
||||||
|
item = QListWidgetItem()
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, date_str)
|
||||||
|
item.setSizeHint(container.sizeHint())
|
||||||
|
|
||||||
|
self.results.addItem(item)
|
||||||
|
self.results.setItemWidget(item, container)
|
||||||
|
|
||||||
|
# --- Snippet/highlight helpers -----------------------------------------
|
||||||
|
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
|
||||||
|
doc = QTextDocument()
|
||||||
|
doc.setHtml(html_src)
|
||||||
|
plain = doc.toPlainText()
|
||||||
|
if not plain:
|
||||||
|
return "", False, False
|
||||||
|
|
||||||
|
tokens = [t for t in re.split(r"\s+", query.strip()) if t]
|
||||||
|
L = len(plain)
|
||||||
|
|
||||||
|
# Find first occurrence (phrase first, then earliest token)
|
||||||
|
idx, mlen = -1, 0
|
||||||
|
if tokens:
|
||||||
|
lower = plain.lower()
|
||||||
|
phrase = " ".join(tokens).lower()
|
||||||
|
j = lower.find(phrase)
|
||||||
|
if j >= 0:
|
||||||
|
idx, mlen = j, len(phrase)
|
||||||
|
else:
|
||||||
|
for t in tokens:
|
||||||
|
tj = lower.find(t.lower())
|
||||||
|
if tj >= 0 and (idx < 0 or tj < idx):
|
||||||
|
idx, mlen = tj, len(t)
|
||||||
|
# Compute window
|
||||||
|
if idx < 0:
|
||||||
|
start, end = 0, min(L, maxlen)
|
||||||
|
else:
|
||||||
|
start = max(0, min(idx - radius, max(0, L - maxlen)))
|
||||||
|
end = min(L, max(idx + mlen + radius, start + maxlen))
|
||||||
|
|
||||||
|
# Bold all token matches that fall inside [start, end)
|
||||||
|
if tokens:
|
||||||
|
lower = plain.lower()
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setFontWeight(QFont.Weight.Bold)
|
||||||
|
for t in tokens:
|
||||||
|
t_low = t.lower()
|
||||||
|
pos = start
|
||||||
|
while True:
|
||||||
|
k = lower.find(t_low, pos)
|
||||||
|
if k == -1 or k >= end:
|
||||||
|
break
|
||||||
|
c = QTextCursor(doc)
|
||||||
|
c.setPosition(k)
|
||||||
|
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
|
||||||
|
c.mergeCharFormat(fmt)
|
||||||
|
pos = k + len(t)
|
||||||
|
|
||||||
|
# Select the window and export as HTML fragment
|
||||||
|
c = QTextCursor(doc)
|
||||||
|
c.setPosition(start)
|
||||||
|
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
||||||
|
fragment_html = (
|
||||||
|
c.selection().toHtml()
|
||||||
|
) # preserves original styles + our bolding
|
||||||
|
|
||||||
|
return fragment_html, start > 0, end < L
|
||||||
|
|
@ -13,17 +13,20 @@ from PySide6.QtWidgets import (
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .db import DBConfig
|
from .db import DBConfig, DBManager
|
||||||
from .settings import save_db_config
|
from .settings import save_db_config
|
||||||
|
from .key_prompt import KeyPrompt
|
||||||
|
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
def __init__(self, cfg: DBConfig, parent=None):
|
def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Settings")
|
self.setWindowTitle("Settings")
|
||||||
self._cfg = DBConfig(path=cfg.path, key="")
|
self._cfg = DBConfig(path=cfg.path, key="")
|
||||||
|
self._db = db
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||||
|
|
@ -44,12 +47,17 @@ class SettingsDialog(QDialog):
|
||||||
h.setStretch(1, 0)
|
h.setStretch(1, 0)
|
||||||
form.addRow("Database path", path_row)
|
form.addRow("Database path", path_row)
|
||||||
|
|
||||||
|
# Change key button
|
||||||
|
self.rekey_btn = QPushButton("Change key")
|
||||||
|
self.rekey_btn.clicked.connect(self._change_key)
|
||||||
|
|
||||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self._save)
|
bb.accepted.connect(self._save)
|
||||||
bb.rejected.connect(self.reject)
|
bb.rejected.connect(self.reject)
|
||||||
|
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
v.addLayout(form)
|
v.addLayout(form)
|
||||||
|
v.addWidget(self.rekey_btn)
|
||||||
v.addWidget(bb)
|
v.addWidget(bb)
|
||||||
|
|
||||||
def _browse(self):
|
def _browse(self):
|
||||||
|
|
@ -67,6 +75,28 @@ class SettingsDialog(QDialog):
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
def _change_key(self):
|
||||||
|
p1 = KeyPrompt(self, title="Change key", message="Enter new key")
|
||||||
|
if p1.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
new_key = p1.key()
|
||||||
|
p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
|
||||||
|
if p2.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
if new_key != p2.key():
|
||||||
|
QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
|
||||||
|
return
|
||||||
|
if not new_key:
|
||||||
|
QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._db.rekey(new_key)
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "Key changed", "The database key was updated."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self) -> DBConfig:
|
def config(self) -> DBConfig:
|
||||||
return self._cfg
|
return self._cfg
|
||||||
|
|
|
||||||
98
bouquin/toolbar.py
Normal file
98
bouquin/toolbar.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Signal, Qt
|
||||||
|
from PySide6.QtGui import QFont, QAction
|
||||||
|
from PySide6.QtWidgets import QToolBar
|
||||||
|
|
||||||
|
|
||||||
|
class ToolBar(QToolBar):
|
||||||
|
boldRequested = Signal(QFont.Weight)
|
||||||
|
italicRequested = Signal()
|
||||||
|
underlineRequested = Signal()
|
||||||
|
strikeRequested = Signal()
|
||||||
|
codeRequested = Signal()
|
||||||
|
headingRequested = Signal(int)
|
||||||
|
bulletsRequested = Signal()
|
||||||
|
numbersRequested = Signal()
|
||||||
|
alignRequested = Signal(Qt.AlignmentFlag)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__("Format", parent)
|
||||||
|
self._build_actions()
|
||||||
|
|
||||||
|
def _build_actions(self):
|
||||||
|
# Bold
|
||||||
|
bold = QAction("Bold", self)
|
||||||
|
bold.setShortcut("Ctrl+B")
|
||||||
|
bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
|
||||||
|
|
||||||
|
italic = QAction("Italic", self)
|
||||||
|
italic.setShortcut("Ctrl+I")
|
||||||
|
italic.triggered.connect(self.italicRequested)
|
||||||
|
|
||||||
|
underline = QAction("Underline", self)
|
||||||
|
underline.setShortcut("Ctrl+U")
|
||||||
|
underline.triggered.connect(self.underlineRequested)
|
||||||
|
|
||||||
|
strike = QAction("Strikethrough", self)
|
||||||
|
strike.setShortcut("Ctrl+-")
|
||||||
|
strike.triggered.connect(self.strikeRequested)
|
||||||
|
|
||||||
|
code = QAction("<code>", self)
|
||||||
|
code.setShortcut("Ctrl+`")
|
||||||
|
code.triggered.connect(self.codeRequested)
|
||||||
|
|
||||||
|
# Headings
|
||||||
|
h1 = QAction("H1", self)
|
||||||
|
h1.setShortcut("Ctrl+1")
|
||||||
|
h2 = QAction("H2", self)
|
||||||
|
h2.setShortcut("Ctrl+2")
|
||||||
|
h3 = QAction("H3", self)
|
||||||
|
h3.setShortcut("Ctrl+3")
|
||||||
|
normal = QAction("Normal", self)
|
||||||
|
normal.setShortcut("Ctrl+P")
|
||||||
|
|
||||||
|
h1.triggered.connect(lambda: self.headingRequested.emit(24))
|
||||||
|
h2.triggered.connect(lambda: self.headingRequested.emit(18))
|
||||||
|
h3.triggered.connect(lambda: self.headingRequested.emit(14))
|
||||||
|
normal.triggered.connect(lambda: self.headingRequested.emit(0))
|
||||||
|
|
||||||
|
# Lists
|
||||||
|
bullets = QAction("• Bullets", self)
|
||||||
|
bullets.triggered.connect(self.bulletsRequested)
|
||||||
|
numbers = QAction("1. Numbered", self)
|
||||||
|
numbers.triggered.connect(self.numbersRequested)
|
||||||
|
|
||||||
|
# Alignment
|
||||||
|
left = QAction("Align Left", self)
|
||||||
|
center = QAction("Align Center", self)
|
||||||
|
right = QAction("Align Right", self)
|
||||||
|
|
||||||
|
left.triggered.connect(
|
||||||
|
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
|
||||||
|
)
|
||||||
|
center.triggered.connect(
|
||||||
|
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
)
|
||||||
|
right.triggered.connect(
|
||||||
|
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addActions(
|
||||||
|
[
|
||||||
|
bold,
|
||||||
|
italic,
|
||||||
|
underline,
|
||||||
|
strike,
|
||||||
|
code,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
normal,
|
||||||
|
bullets,
|
||||||
|
numbers,
|
||||||
|
left,
|
||||||
|
center,
|
||||||
|
right,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
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"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
repository = "https://git.mig5.net/mig5/bouquin"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.9,<3.14"
|
python = ">=3.9,<3.14"
|
||||||
|
|
|
||||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 86 KiB |
|
|
@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot):
|
||||||
win, *_ = patched_main_window
|
win, *_ = patched_main_window
|
||||||
|
|
||||||
# Type into the editor and save
|
# Type into the editor and save
|
||||||
win.editor.setPlainText("Test note")
|
win.editor.setHtml("Test note")
|
||||||
win._save_current(explicit=True) # call directly to avoid waiting timers
|
win._save_current(explicit=True) # call directly to avoid waiting timers
|
||||||
|
|
||||||
day = win._current_date_iso()
|
day = win._current_date_iso()
|
||||||
assert win.db.get_entry(day) == "Test note"
|
assert "Test note" in win.db.get_entry(day)
|
||||||
|
|
||||||
|
|
||||||
def test_switch_day_saves_previous(patched_main_window, qtbot):
|
def test_switch_day_saves_previous(patched_main_window, qtbot):
|
||||||
|
|
@ -45,13 +45,13 @@ def test_switch_day_saves_previous(patched_main_window, qtbot):
|
||||||
# Write on Day 1
|
# Write on Day 1
|
||||||
d1 = win.calendar.selectedDate()
|
d1 = win.calendar.selectedDate()
|
||||||
d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
|
d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
|
||||||
win.editor.setPlainText("Notes day 1")
|
win.editor.setHtml("Notes day 1")
|
||||||
|
|
||||||
# Trigger a day change (this path calls _on_date_changed via signal)
|
# Trigger a day change (this path calls _on_date_changed via signal)
|
||||||
d2 = d1.addDays(1)
|
d2 = d1.addDays(1)
|
||||||
win.calendar.setSelectedDate(d2)
|
win.calendar.setSelectedDate(d2)
|
||||||
# After changing, previous day should be saved; editor now shows day 2 content (empty)
|
# After changing, previous day should be saved; editor now shows day 2 content (empty)
|
||||||
assert win.db.get_entry(d1_iso) == "Notes day 1"
|
assert "Notes day 1" in win.db.get_entry(d1_iso)
|
||||||
assert win.editor.toPlainText() == ""
|
assert win.editor.toPlainText() == ""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue