diff --git a/CHANGELOG.md b/CHANGELOG.md index 5966eb0..47c0cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Improve search results window and highlight in calendar when there are matches. * Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL. + * Add ability to export to Markdown (and fix heading styles) # 0.1.9 diff --git a/bouquin/db.py b/bouquin/db.py index 82b195f..68f956d 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -6,6 +6,7 @@ import json import os from dataclasses import dataclass +from markdownify import markdownify as md from pathlib import Path from sqlcipher3 import dbapi2 as sqlite from typing import List, Sequence, Tuple @@ -430,6 +431,29 @@ class DBManager: with open(file_path, "w", encoding="utf-8") as f: f.write("\n".join(parts)) + def export_markdown( + self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" + ) -> None: + parts = [ + "", + '', + "", + f"

{html.escape(title)}

", + ] + for d, c in entries: + parts.append( + f"
{c}
" + ) + parts.append("") + + # Convert html to markdown + md_items = [] + for item in parts: + md_items.append(md(item, heading_style="ATX")) + + with open(file_path, "w", encoding="utf-8") as f: + f.write("\n".join(md_items)) + def export_sql(self, file_path: str) -> None: """ Exports the encrypted database as plaintext SQL. diff --git a/bouquin/editor.py b/bouquin/editor.py index b7fc341..296ca34 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -65,8 +65,9 @@ class Editor(QTextEdit): def _is_heading_typing(self) -> bool: """Is the current *insertion* format using a heading size?""" - s = self.currentCharFormat().fontPointSize() or self.font().pointSizeF() - return any(self._approx(s, h) for h in self._HEADING_SIZES) + bf = self.textCursor().blockFormat() + if bf.headingLevel() > 0: + return True def _apply_normal_typing(self): """Switch the *insertion* format to Normal (default size, normal weight).""" @@ -611,20 +612,43 @@ class Editor(QTextEdit): Set heading point size for typing. If there's a selection, also apply bold to that selection (for H1..H3). "Normal" clears bold on the selection. """ - base_size = size if size else self.font().pointSizeF() + # Map toolbar's sizes to heading levels + level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0 + c = self.textCursor() - # Update the typing (insertion) format to be size only, but don't represent - # it as if the Bold style has been toggled on + # On-screen look ins = QTextCharFormat() - ins.setFontPointSize(base_size) + if size: + ins.setFontPointSize(float(size)) + ins.setFontWeight(QFont.Weight.Bold) + else: + ins.setFontPointSize(self.font().pointSizeF()) + ins.setFontWeight(QFont.Weight.Normal) self.mergeCurrentCharFormat(ins) - # If user selected text, style that text visually as a heading + # Apply heading level to affected block(s) + def set_level_for_block(cur): + bf = cur.blockFormat() + if hasattr(bf, "setHeadingLevel"): + bf.setHeadingLevel(level) # 0 clears heading + cur.mergeBlockFormat(bf) + if c.hasSelection(): - sel = QTextCharFormat(ins) - sel.setFontWeight(QFont.Weight.Bold if size else QFont.Weight.Normal) - c.mergeCharFormat(sel) + start, end = c.selectionStart(), c.selectionEnd() + bc = QTextCursor(self.document()) + bc.setPosition(start) + while True: + set_level_for_block(bc) + if bc.position() >= end: + break + bc.movePosition(QTextCursor.EndOfBlock) + if bc.position() >= end: + break + bc.movePosition(QTextCursor.NextBlock) + else: + bc = QTextCursor(c) + set_level_for_block(bc) def toggle_bullets(self): c = self.textCursor() diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 693456b..cc01f6d 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -616,6 +616,7 @@ If you want an encrypted backup, choose Backup instead of Export. "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;" + "Markdown (*.md);;" "SQL (*.sql);;" ) @@ -631,6 +632,7 @@ If you want an encrypted backup, choose Backup instead of Export. "JSON (*.json)": ".json", "CSV (*.csv)": ".csv", "HTML (*.html)": ".html", + "Markdown (*.md)": ".md", "SQL (*.sql)": ".sql", }.get(selected_filter, ".txt") @@ -647,6 +649,8 @@ If you want an encrypted backup, choose Backup instead of Export. self.db.export_csv(entries, filename) elif selected_filter.startswith("HTML"): self.db.export_html(entries, filename) + elif selected_filter.startswith("Markdown"): + self.db.export_markdown(entries, filename) elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: diff --git a/poetry.lock b/poetry.lock index e1c4ed5..87acb50 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,27 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"}, + {file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"}, +] + +[package.dependencies] +soupsieve = ">1.2" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "colorama" version = "0.4.6" @@ -158,6 +180,21 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "markdownify" +version = "1.2.0" +description = "Convert HTML to markdown." +optional = false +python-versions = "*" +files = [ + {file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"}, + {file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"}, +] + +[package.dependencies] +beautifulsoup4 = ">=4.9,<5" +six = ">=1.15,<2" + [[package]] name = "packaging" version = "25.0" @@ -345,6 +382,28 @@ files = [ {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "soupsieve" +version = "2.8" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"}, + {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"}, +] + [[package]] name = "sqlcipher3-wheels" version = "0.5.5.post0" @@ -541,4 +600,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.14" -content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4" +content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d" diff --git a/pyproject.toml b/pyproject.toml index 30c541a..bcedf1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ repository = "https://git.mig5.net/mig5/bouquin" python = ">=3.9,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" +markdownify = "^1.2.0" [tool.poetry.scripts] bouquin = "bouquin.__main__:main"