From bca5d1bf8add17fe164dabb0c91780200e1139d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Gon=C3=A7alves?= Date: Fri, 22 May 2026 18:52:56 -0300 Subject: [PATCH] Fix Markdown/bulk export review follow-ups from PR #19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the ten code-review findings on the freshly-merged Markdown + bulk export workflow. No new features; correctness and UI/UX only. Converter (tsv_odf_converter.py): - _MD_LINE_START_RE: require whitespace (or end of string) after the digits-dot pattern so paragraphs starting with a decimal like "1.5 million" or "2025.06 release" are no longer mangled to "\1.5 million". - _emit_kv: emit blank lines before and after so adjacent KV elements no longer collapse into one CommonMark paragraph and the bolded key stays visually distinct. - _emit_paragraph: when DocElement.raw_lines preserved per-line breaks (multi-line addresses, poetry), emit each line with a CommonMark hard break (two trailing spaces) instead of collapsing into a single run-on line. Markdown export UI (conclusion_export_mixin.py): - _export_markdown_file: write to ".tmp" then os.replace so a failed/cancelled conversion no longer destroys a pre-existing file the user picked to overwrite via FileDialog. - _on_md_save_response: guard against Gio.File.get_path() == None (remote/MTP/GVfs locations) and surface a clear toast instead of an AttributeError swallowed as a generic "Export failed". - _on_md_export_clicked → _on_md_save_response → _export_markdown_file → _on_md_export_finished: pass include_front_matter and open_after through the closure chain instead of self attributes, so overlapping per-row exports can no longer clobber each other's settings. - _on_folder_chosen (bulk): show _EXPORT_FAILED_MSG on non-Dismiss errors so the user gets feedback instead of a silently-vanishing dialog; also guards remote folders with the same toast as the single-file path. - _run_bulk_export / _bulk_convert_one: snapshot md_include_front_matter and odf_include_images once at batch start and pass them via an options dict, so a mid-batch toggle from another dialog can no longer produce a non-uniform batch. - _bulk_convert_one (md branch): write to ".tmp" and os.replace, matching the single-file atomic-write pattern. - _bulk_export_worker: when fmt is not in _BULK_EXTENSIONS, close the dialog and toast a real export-failed message instead of falling back to a misleading "Saved 0 files". - _build_progress_dialog: cancel handler now disables the button, swaps its label and AT-SPI accessible name to "Cancelling…", and rewrites the subtitle to "Finishing current step…". The progress callback short-circuits after cancel so the message doesn't get overwritten by late per-file updates. Gives the user immediate feedback while parse_tsv_pages finishes its current step on a long PDF. Settings persistence (services/settings.py): - Add _load_md_settings / _save_md_settings handling the md_export.include_front_matter and md_export.open_after_export config keys; hooked into load_settings / _save_all_settings. - _update_md_setting (UI) now calls settings._save_md_settings() before config.save() so the Markdown toggles actually persist across restarts, matching the ODF flow. Tests (test_markdown_export.py): - TestEscapeMd.test_decimal_at_line_start_not_escaped: pins the new decimal-aware behavior; "1." alone still escapes via the end-of- string boundary. - TestCreateMarkdown.test_kv_elements_separated_by_blank_line: pins the new KV separator rule. - TestCreateMarkdown.test_paragraph_preserves_raw_lines: pins the new hard-break handling for multi-line OCR paragraphs. - TestSaveMdSettings.{test_save_md_settings_writes_both_keys, test_load_md_settings_reads_both_keys, test_save_md_settings_defaults_when_unset}: cover the new persistence path end-to-end with a minimal config double. 364 passing tests, ruff clean. --- src/bigocrpdf/services/settings.py | 48 ++++-- src/bigocrpdf/ui/conclusion_export_mixin.py | 157 +++++++++++++++----- src/bigocrpdf/utils/tsv_odf_converter.py | 29 +++- tests/test_markdown_export.py | 112 ++++++++++++++ 4 files changed, 294 insertions(+), 52 deletions(-) diff --git a/src/bigocrpdf/services/settings.py b/src/bigocrpdf/services/settings.py index 86cdc60..673645b 100644 --- a/src/bigocrpdf/services/settings.py +++ b/src/bigocrpdf/services/settings.py @@ -142,6 +142,7 @@ def load_settings(self) -> None: self._load_date_settings() self._load_text_extraction_settings() self._load_odf_settings() + self._load_md_settings() self._load_preprocessing_settings() self._load_image_export_settings() self._load_pdf_output_settings() @@ -197,6 +198,10 @@ def _load_odf_settings(self) -> None: self.odf_use_formatting = self._config.get("odf_export.use_formatting", True) self.odf_open_after_export = self._config.get("odf_export.open_after_export", False) + def _load_md_settings(self) -> None: + self.md_include_front_matter = self._config.get("md_export.include_front_matter", False) + self.md_open_after_export = self._config.get("md_export.open_after_export", False) + def _load_preprocessing_settings(self) -> None: self.dpi = self._config.get("rapidocr.dpi", DEFAULT_DPI) self.enable_preprocessing = self._config.get( @@ -473,6 +478,7 @@ def _save_all_settings(self) -> None: self._save_text_extraction_settings() self._save_editor_settings() self._save_odf_settings() + self._save_md_settings() self._save_preprocessing_settings() self._save_image_export_settings() self._save_pdf_output_settings() @@ -536,6 +542,18 @@ def _save_odf_settings(self) -> None: "odf_export.open_after_export", self.odf_open_after_export, save_immediately=False ) + def _save_md_settings(self) -> None: + self._config.set( + "md_export.include_front_matter", + getattr(self, "md_include_front_matter", False), + save_immediately=False, + ) + self._config.set( + "md_export.open_after_export", + getattr(self, "md_open_after_export", False), + save_immediately=False, + ) + def _save_preprocessing_settings(self) -> None: self._config.set("rapidocr.dpi", self.dpi, save_immediately=False) self._config.set("rapidocr.language", self.ocr_language, save_immediately=False) @@ -641,20 +659,26 @@ def get_pdf_suffix(self) -> str: # Add date elements with their preferred order if self.include_year: - date_components.append(( - self.date_format_order.get("year", 1), - f"{now.tm_year}", - )) + date_components.append( + ( + self.date_format_order.get("year", 1), + f"{now.tm_year}", + ) + ) if self.include_month: - date_components.append(( - self.date_format_order.get("month", 2), - f"{now.tm_mon:02d}", - )) + date_components.append( + ( + self.date_format_order.get("month", 2), + f"{now.tm_mon:02d}", + ) + ) if self.include_day: - date_components.append(( - self.date_format_order.get("day", 3), - f"{now.tm_mday:02d}", - )) + date_components.append( + ( + self.date_format_order.get("day", 3), + f"{now.tm_mday:02d}", + ) + ) # Sort components by their position value date_components.sort(key=lambda x: x[0]) diff --git a/src/bigocrpdf/ui/conclusion_export_mixin.py b/src/bigocrpdf/ui/conclusion_export_mixin.py index c6b7757..a06f0a6 100644 --- a/src/bigocrpdf/ui/conclusion_export_mixin.py +++ b/src/bigocrpdf/ui/conclusion_export_mixin.py @@ -399,7 +399,20 @@ def _build_progress_dialog( cancel_btn.set_halign(Gtk.Align.CENTER) cancel_btn.set_margin_top(8) set_a11y_label(cancel_btn, _("Cancel")) - cancel_btn.connect("clicked", lambda _b: cancel_event.set()) + + def _on_cancel(_b: Gtk.Button) -> None: + # Give immediate feedback: the worker may stay inside a long + # parse step before it next polls the cancel event, so we + # update the dialog UI (label + disabled button) on the main + # thread instead of leaving the user staring at an unchanged + # spinner. + cancel_event.set() + cancel_btn.set_sensitive(False) + cancel_btn.set_label(_("Cancelling…")) + set_a11y_label(cancel_btn, _("Cancelling…")) + subtitle_label.set_text(_("Finishing current step…")) + + cancel_btn.connect("clicked", _on_cancel) box.append(cancel_btn) toolbar_view.set_content(box) @@ -407,6 +420,11 @@ def _build_progress_dialog( dialog.present(self.window) def update_progress(done: int, name: str) -> bool: + # Once the user clicks Cancel we keep the "Finishing current + # step…" message and stop overwriting it with per-file progress + # so they don't see a fresh filename after asking to stop. + if cancel_event.is_set(): + return False if total: subtitle_label.set_text(f"{done}/{total} — {name}") if progress_bar is not None: @@ -494,7 +512,9 @@ def _update_md_setting(self, attr: str, value: bool, state: dict, key: str) -> N state[key] = value settings = self.window.settings setattr(settings, attr, value) - # Persist if the settings object supports it (graceful no-op otherwise). + save_md = getattr(settings, "_save_md_settings", None) + if callable(save_md): + save_md() config = getattr(settings, "_config", None) if config is not None and hasattr(config, "save"): config.save() @@ -506,13 +526,18 @@ def _on_md_export_clicked( file_path: str, options_dialog: Adw.Dialog, ) -> None: - """Handle the Export button click for Markdown.""" - self._md_include_front_matter = include_front_matter - self._md_open_after = open_after + """Handle the Export button click for Markdown. + + The selected option values are pinned to the closure passed to the + file picker so overlapping per-row exports don't clobber each other's + settings via shared self attributes. + """ options_dialog.force_close() - self._show_markdown_file_dialog(file_path) + self._show_markdown_file_dialog(file_path, include_front_matter, open_after) - def _show_markdown_file_dialog(self, file_path: str) -> None: + def _show_markdown_file_dialog( + self, file_path: str, include_front_matter: bool, open_after: bool + ) -> None: """Show file save dialog for Markdown export.""" from gi.repository import Gio @@ -536,28 +561,49 @@ def _show_markdown_file_dialog(self, file_path: str) -> None: save_dialog.save( parent=self.window, cancellable=None, - callback=lambda d, r: self._on_md_save_response(d, r, file_path), + callback=lambda d, r: self._on_md_save_response( + d, r, file_path, include_front_matter, open_after + ), ) - def _on_md_save_response(self, dialog: Gtk.FileDialog, result, file_path: str) -> None: + def _on_md_save_response( + self, + dialog: Gtk.FileDialog, + result, + file_path: str, + include_front_matter: bool, + open_after: bool, + ) -> None: """Handle the Markdown save dialog response.""" try: file = dialog.save_finish(result) output_path = file.get_path() + if output_path is None: + logger.error("Markdown export destination has no local path (remote URI)") + self.window.show_toast(_("Remote locations are not supported")) + return if not output_path.lower().endswith((".md", ".markdown")): output_path += ".md" - self._export_markdown_file(output_path, file_path) + self._export_markdown_file(output_path, file_path, include_front_matter, open_after) except Exception as e: if not self._is_user_dismissed(e): logger.error(f"Error exporting to Markdown: {e}") self.window.show_toast(_EXPORT_FAILED_MSG) - def _export_markdown_file(self, output_path: str, file_path: str) -> None: + def _export_markdown_file( + self, + output_path: str, + file_path: str, + include_front_matter: bool, + open_after: bool, + ) -> None: """Convert PDF to Markdown in a background thread. Mirrors the ODF flow: a cancellable progress dialog stays on screen until the conversion finishes (or the user cancels) so large PDFs - don't appear to freeze the app. + don't appear to freeze the app. Writes go through a sibling + ``.tmp`` file and ``os.replace`` so an existing target file is + preserved if conversion fails or the user cancels. """ import threading @@ -565,7 +611,6 @@ def _export_markdown_file(self, output_path: str, file_path: str) -> None: from bigocrpdf.utils.odf_builder import ExportCancelled - include_fm = getattr(self, "_md_include_front_matter", False) loading_dialog, _update, cancel_event = self._build_progress_dialog( _("Exporting to Markdown…"), os.path.basename(file_path), @@ -576,15 +621,19 @@ def _do_export() -> None: success = False cancelled = False + tmp_path = output_path + ".tmp" try: text = convert_pdf_to_markdown( file_path, - include_front_matter=include_fm, + include_front_matter=include_front_matter, cancel_event=cancel_event, ) + if cancel_event.is_set(): + raise ExportCancelled os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - with open(output_path, "w", encoding="utf-8") as fh: + with open(tmp_path, "w", encoding="utf-8") as fh: fh.write(text) + os.replace(tmp_path, output_path) success = True except ExportCancelled: cancelled = True @@ -592,14 +641,15 @@ def _do_export() -> None: except Exception as e: logger.error(f"Markdown conversion failed: {e}") - if not success and os.path.exists(output_path): - try: - os.remove(output_path) - except OSError: - pass + self._safe_remove(tmp_path) GLib.idle_add( - self._on_md_export_finished, loading_dialog, success, cancelled, output_path + self._on_md_export_finished, + loading_dialog, + success, + cancelled, + output_path, + open_after, ) threading.Thread(target=_do_export, daemon=True).start() @@ -610,6 +660,7 @@ def _on_md_export_finished( success: bool, cancelled: bool, output_path: str, + open_after: bool = False, ) -> bool: """Report Markdown export result on the main thread.""" dialog.force_close() @@ -617,7 +668,7 @@ def _on_md_export_finished( self.window.show_toast(_("Export cancelled")) elif success: self.window.show_toast(_("Exported to {}").format(os.path.basename(output_path))) - if getattr(self, "_md_open_after", False): + if open_after: from bigocrpdf.utils.pdf_utils import open_file_with_default_app open_file_with_default_app(output_path) @@ -674,15 +725,23 @@ def _on_folder_chosen(d: Gtk.FileDialog, result: Gio.AsyncResult) -> None: except Exception as e: if not self._is_user_dismissed(e): logger.error(f"Folder picker failed: {e}") + self.window.show_toast(_EXPORT_FAILED_MSG) return folder_path = folder.get_path() - if folder_path: - self._run_bulk_export(files, folder_path, fmt) + if folder_path is None: + self.window.show_toast(_("Remote locations are not supported")) + return + self._run_bulk_export(files, folder_path, fmt) dialog.select_folder(parent=self.window, cancellable=None, callback=_on_folder_chosen) def _run_bulk_export(self, files: list[str], dest_folder: str, fmt: str) -> None: - """Bulk export entry point — validates the destination and spawns the worker.""" + """Bulk export entry point — validates the destination and spawns the worker. + + Per-format settings are snapshotted here at batch start so a mid-batch + toggle from another dialog can't make some files honour different + options than others. + """ import threading # Cheap early checks so the user gets a clear error instead of @@ -694,6 +753,12 @@ def _run_bulk_export(self, files: list[str], dest_folder: str, fmt: str) -> None self.window.show_toast(_("Destination folder is not writable")) return + settings = self.window.settings + options = { + "include_front_matter": getattr(settings, "md_include_front_matter", False), + "include_images": getattr(settings, "odf_include_images", True), + } + total = len(files) loading_dialog, update_progress, cancel_event = self._build_progress_dialog( _("Exporting selected files…"), @@ -703,7 +768,15 @@ def _run_bulk_export(self, files: list[str], dest_folder: str, fmt: str) -> None threading.Thread( target=self._bulk_export_worker, - args=(files, dest_folder, fmt, cancel_event, update_progress, loading_dialog), + args=( + files, + dest_folder, + fmt, + options, + cancel_event, + update_progress, + loading_dialog, + ), daemon=True, ).start() @@ -718,9 +791,19 @@ def _safe_remove(path: str) -> None: except OSError: pass - def _bulk_convert_one(self, pdf_path: str, out_path: str, fmt: str, cancel_event) -> None: + def _bulk_convert_one( + self, + pdf_path: str, + out_path: str, + fmt: str, + options: dict, + cancel_event, + ) -> None: """Convert *pdf_path* into *out_path* using the requested *fmt*. + ``options`` carries the per-format flags snapshotted at batch start + (``include_front_matter`` for Markdown, ``include_images`` for ODF). + Raises ``ExportCancelled`` if the user cancels mid-file, or any other converter exception on failure — the caller is responsible for recording the outcome and cleaning up the partial file. @@ -728,23 +811,23 @@ def _bulk_convert_one(self, pdf_path: str, out_path: str, fmt: str, cancel_event if fmt == "md": from bigocrpdf.utils.tsv_odf_converter import convert_pdf_to_markdown - include_fm = getattr(self.window.settings, "md_include_front_matter", False) text = convert_pdf_to_markdown( pdf_path, - include_front_matter=include_fm, + include_front_matter=options.get("include_front_matter", False), cancel_event=cancel_event, ) - with open(out_path, "w", encoding="utf-8") as fh: + tmp_path = out_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: fh.write(text) + os.replace(tmp_path, out_path) return from bigocrpdf.utils.tsv_odf_converter import convert_pdf_to_odf - include_images = getattr(self.window.settings, "odf_include_images", True) convert_pdf_to_odf( pdf_path, out_path, - include_images=include_images, + include_images=options.get("include_images", True), cancel_event=cancel_event, ) @@ -753,6 +836,7 @@ def _bulk_export_worker( files: list[str], dest_folder: str, fmt: str, + options: dict, cancel_event, update_progress, loading_dialog: Adw.Dialog, @@ -762,7 +846,12 @@ def _bulk_export_worker( from bigocrpdf.utils.odf_builder import ExportCancelled - ext = self._BULK_EXTENSIONS.get(fmt, ".md") + if fmt not in self._BULK_EXTENSIONS: + logger.error("Unknown bulk export format: %s", fmt) + GLib.idle_add(loading_dialog.force_close) + GLib.idle_add(self.window.show_toast, _EXPORT_FAILED_MSG) + return + ext = self._BULK_EXTENSIONS[fmt] results: dict = {"ok": 0, "failed": [], "saved_paths": []} for idx, pdf_path in enumerate(files, start=1): @@ -774,7 +863,7 @@ def _bulk_export_worker( GLib.idle_add(update_progress, idx, os.path.basename(out_path)) try: - self._bulk_convert_one(pdf_path, out_path, fmt, cancel_event) + self._bulk_convert_one(pdf_path, out_path, fmt, options, cancel_event) except ExportCancelled: # User pressed Cancel mid-file — bail out without recording # this file as a failure, and clean up the partial output. diff --git a/src/bigocrpdf/utils/tsv_odf_converter.py b/src/bigocrpdf/utils/tsv_odf_converter.py index ec7742e..a29bcfb 100644 --- a/src/bigocrpdf/utils/tsv_odf_converter.py +++ b/src/bigocrpdf/utils/tsv_odf_converter.py @@ -397,7 +397,10 @@ def convert_pdf_to_text(pdf_path: str) -> str: # '#', '-', '+', '>' and 'N.' lists is handled separately so we don't uglify # mid-paragraph text (e.g. CPF/phone numbers full of hyphens). _MD_INLINE_ESCAPE_RE = re.compile(r"([\\`*_\[\]<>|])") -_MD_LINE_START_RE = re.compile(r"^([#\-+>]|\d+\.)") +# Ordered-list marker requires whitespace after the dot in CommonMark; without +# the lookahead a paragraph starting with a decimal like "1.5 million" would be +# wrongly escaped to "\1.5 million". +_MD_LINE_START_RE = re.compile(r"^([#\-+>]|\d+\.(?=\s|$))") def _escape_md(text: str) -> str: @@ -495,18 +498,32 @@ def _emit_table(lines: list[str], elem: DocElement) -> None: def _emit_kv(lines: list[str], elem: DocElement) -> None: """Bold the key portion (before the first colon) for readability.""" + _ensure_blank_line(lines) text = elem.text.strip() if ":" not in text: lines.append(_escape_md(text)) - return - key, _sep, value = text.partition(":") - lines.append(f"**{_escape_md(key.strip())}:** {_escape_md(value.strip())}") + else: + key, _sep, value = text.partition(":") + lines.append(f"**{_escape_md(key.strip())}:** {_escape_md(value.strip())}") + lines.append("") def _emit_paragraph(lines: list[str], elem: DocElement) -> None: - """Paragraph variants (paragraph, paragraph_indent, paragraph_right, …).""" + """Paragraph variants (paragraph, paragraph_indent, paragraph_right, …). + + When the OCR layer preserved per-line breaks in ``raw_lines`` (multi-line + addresses, poetry, etc.), emit each line separately with a CommonMark + hard break (two trailing spaces) so the rendered output keeps the + original line geometry instead of collapsing into one run-on paragraph. + """ _ensure_blank_line(lines) - lines.append(_escape_md(elem.text.strip())) + raw = [line for line in (elem.raw_lines or []) if line.strip()] + if len(raw) > 1: + for i, line in enumerate(raw): + suffix = " " if i < len(raw) - 1 else "" + lines.append(_escape_md(line.strip()) + suffix) + else: + lines.append(_escape_md(elem.text.strip())) lines.append("") diff --git a/tests/test_markdown_export.py b/tests/test_markdown_export.py index 66503d9..042cb9e 100644 --- a/tests/test_markdown_export.py +++ b/tests/test_markdown_export.py @@ -59,6 +59,16 @@ def test_asterisk_at_line_start_inline_escaped(self): # line-start regex, so the inline rule is what kicks in. assert _escape_md("*emphasized* mid") == r"\*emphasized\* mid" + def test_decimal_at_line_start_not_escaped(self): + # "1.5 million" must not be mangled to "\1.5 million"; the + # ordered-list rule applies only when the dot is followed by + # whitespace (or end of string). + assert _escape_md("1.5 million users") == "1.5 million users" + assert _escape_md("2025.06 release") == "2025.06 release" + # End-of-string also counts as a list-marker boundary so "1." alone + # stays escaped. + assert _escape_md("1.") == r"\1." + class TestEscapeMdCell: """Table cells need inline escapes but no line-start rules.""" @@ -169,6 +179,35 @@ def test_paragraph_special_chars_escaped(self): assert r"\_underscores\_" in md assert r"\*stars\*" in md + def test_kv_elements_separated_by_blank_line(self): + # Adjacent kv elements must be separated so CommonMark renders them + # as distinct paragraphs instead of one soft-wrapped block. + pages = [ + [ + DocElement("kv", "Author: Jane"), + DocElement("kv", "Date: 2025"), + ] + ] + md = create_markdown(pages) + # There must be a blank line between the two bolded keys. + assert "**Author:** Jane\n\n**Date:** 2025" in md + + def test_paragraph_preserves_raw_lines(self): + # When the OCR layer captured per-line breaks, the Markdown output + # keeps them via CommonMark hard breaks (two trailing spaces). + pages = [ + [ + DocElement( + "paragraph", + "Rua X 123 Bairro Y CEP 00000-000", + raw_lines=["Rua X 123", "Bairro Y", "CEP 00000-000"], + ) + ] + ] + md = create_markdown(pages) + # Hard break = two trailing spaces before the newline. + assert "Rua X 123 \nBairro Y \nCEP 00000-000" in md + class TestConvertPdfToMarkdown: def test_returns_empty_when_no_text(self): @@ -301,3 +340,76 @@ def test_other_errors_pass_through(self): assert not ConclusionExportMixin._is_user_dismissed(RuntimeError("Disk full")) assert not ConclusionExportMixin._is_user_dismissed(FileNotFoundError("nope")) + + +class TestSaveMdSettings: + """MD export toggles persist through the same config flow as ODF.""" + + def test_save_md_settings_writes_both_keys(self): + from bigocrpdf.services.settings import OcrSettings + + # Minimal config double — record .set() calls and replay .get(). + store: dict = {} + + class _Cfg: + def get(self, key, default=None): + return store.get(key, default) + + def set(self, key, value, save_immediately=False): + store[key] = value + + def save(self): + pass + + s = OcrSettings.__new__(OcrSettings) + s._config = _Cfg() # type: ignore[attr-defined] + s.md_include_front_matter = True + s.md_open_after_export = True + s._save_md_settings() + + assert store.get("md_export.include_front_matter") is True + assert store.get("md_export.open_after_export") is True + + def test_load_md_settings_reads_both_keys(self): + from bigocrpdf.services.settings import OcrSettings + + class _Cfg: + def __init__(self): + self.store = { + "md_export.include_front_matter": True, + "md_export.open_after_export": True, + } + + def get(self, key, default=None): + return self.store.get(key, default) + + s = OcrSettings.__new__(OcrSettings) + s._config = _Cfg() # type: ignore[attr-defined] + s._load_md_settings() + + assert s.md_include_front_matter is True + assert s.md_open_after_export is True + + def test_save_md_settings_defaults_when_unset(self): + # Settings object freshly constructed may not have the attributes — + # the saver must default to False instead of raising AttributeError. + from bigocrpdf.services.settings import OcrSettings + + store: dict = {} + + class _Cfg: + def get(self, key, default=None): + return store.get(key, default) + + def set(self, key, value, save_immediately=False): + store[key] = value + + def save(self): + pass + + s = OcrSettings.__new__(OcrSettings) + s._config = _Cfg() # type: ignore[attr-defined] + s._save_md_settings() # no attributes set yet + + assert store["md_export.include_front_matter"] is False + assert store["md_export.open_after_export"] is False