Skip to content

Latest commit

 

History

History
138 lines (116 loc) · 62.7 KB

File metadata and controls

138 lines (116 loc) · 62.7 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • Plugins can contribute to the sidebar and the header bar through helpers that clean up after themselves: MiAZPlugin.add_sidebar_widget(widget, widget_key=None), add_headerbar_widget(widget, position='left'|'right', widget_key=None) and add_sidebar_dropdown(dropdown). The last one replaces five setup steps and their five teardown steps: sizing, the shared size group, the plugin-dropdowns list, the widget key, and the icon row every filter dropdown was building by hand. A PluginWidgetRegistry records one undo step per contribution and unload_plugin runs them, most recent first, skipping any that fails so one detached widget cannot strand the rest. MiAZNotes, MiAZProjectMgt, MiAZPeriodicity and MiAZFullscreen migrated. These are additions, not restrictions: reaching sidebar-plugin-section or headerbar-left-box directly still works.
  • New scripts/devel/check_plugin_ui.py: drives a real disable/enable cycle in the running app and reports what every shared container (workspace stack, sidebar section, both header bar boxes, the dropdown list) held at each step. Covers what unit tests cannot, since it needs a display and a loaded repository. With no arguments it checks the four plugins that contribute UI.
  • The plugin system now owns the workspace pages plugins contribute. MiAZPlugin.add_workspace_page records the page against the plugin (PluginPageRegistry), and unload_plugin removes it, next to where it already removes that plugin's web content and rename-dialog tabs. Covered by 9 new tests in tests/test_pluginsystem.py.
  • New backend module MiAZ/backend/gate.py (UpdateGate) and MiAZWorkspace.suspend_updates(): hold back workspace refreshes during bulk work and collapse them into one. It is reference counted and returns a handle, so it nests, survives two operations overlapping, and works across a thread boundary (GLib.idle_add(handle.release)); release() is idempotent, and a callback that raises cannot leave the gate shut. Covered by tests/test_gate.py (15 tests).
  • New MiAZConfigStore in MiAZ/backend/config.py: one owner for the eight configurations of a repository and for the cache they share, created by repository.load() and disposed when another repository is loaded. MiAZRepository.get_config_store() exposes it; app.get_config(name) is unchanged, since the store publishes its configs into the same registry. Covered by tests/test_configstore.py (14 tests) plus 6 new repository-switch tests in tests/test_repository.py.
  • New backend module MiAZ/backend/tasks.py (run_in_background(fn, on_done=None, on_error=None, name=None)): runs work in a daemon thread and delivers the result back through GLib.idle_add, so callbacks can touch widgets. Without an on_error a failure is logged with its traceback, which is the reason to prefer it over a raw threading.Thread: an exception in a worker thread dies where nobody sees it unless every caller remembers to wrap its own body, and most did not. Covered by tests/test_tasks.py (12 tests) which pump the real GLib main context rather than mocking the marshalling.
  • New tests/test_boundaries.py: the two architectural rules AGENTS.md states are now enforced instead of documented. test_backend_imports_no_gui_toolkit fails if anything under MiAZ/backend/ imports Gtk, Adw, Gdk, Pango, GdkPixbuf or WebKit (GObject, GLib and Gio stay allowed, they are the backend/frontend signal contract). test_frontend_does_not_touch_the_filesystem_directly fails if a frontend module calls os.rename, os.unlink, shutil.copy, shutil.rmtree and friends instead of going through the util service. Two modules are allowlisted with a written reason, and a third test fails when an allowlist entry stops being needed, so an exemption cannot quietly outlive its cause. The detectors are themselves tested, so a passing rule cannot mean a broken detector.
  • New backend module MiAZ/backend/query.py (DocumentQuery): the workspace filter as a value instead of the combined state of six widgets. It carries the free-text search, the concept substring, the five field selections, the date mode and its bounds, the review flag and two lifted checks (ignore_date, ignore_active), and answers matches(item) as a pure function that reads no widget and no app. to_dict() / from_dict() round-trip it through plain JSON types, which is what saved searches will need; from_dict ignores unknown keys so a search written by a newer version does not break an older one. Covered by tests/test_query.py (37 tests), including a matrix of 2016 comparisons against a verbatim transcription of the widget code it replaces.
  • MiAZWorkspace.set_query(query) now writes the query into the sidebar controls and reads it back, so the view and the sidebar always agree. It used to set the filter in memory only: the sidebar kept showing something else, and the next change to any control rebuilt the query from those controls, silently discarding the applied filter. It returns the fields it could not represent rather than dropping them quietly. DocumentQuery gained date_preset, a stable token naming which sidebar date entry produced the range; the entries are relative to today and their labels are translated, so neither the resolved dates nor the visible text can identify one later. Reading the query back is what resolves the token, so a saved search storing "this month" means the month it is opened in, not the month it was saved in. models.Date carries the token.
  • MiAZWorkspace.register_query_hook(name, callback) / unregister_query_hook(name). The callback receives the query after it is read from the widgets and may adjust it. This is the missing half of register_filter_view, which can only AND an extra condition in and so could never relax one.
  • The Projects tab of the rename dialog has a Manage projects button. Its empty state told the user to create a project "from Projects management" without saying where that is: the only way in was the workspace menu (Projects > Manage projects, Ctrl+Alt+P), which is not reachable while the rename dialog is open. The button opens the same manager over the rename window, and the tab rebuilds its list when the manager closes, keeping the boxes the user had already ticked and leaving new projects unticked. MiAZProjectMgt.show_settings() returns its dialog so the tab can wait for it to close.
  • New backend module MiAZ/backend/index.py (MiAZDocumentIndex, registered as the index service): the in-memory view of the repository. It owns the only path from a filename to a MiAZItem (build_item), the description cache, the field index and the pending set, and exposes reload(), apply_change(path, event, other=None), documents(), document(id), pending(), invalid(), field_index(), concepts() and invalidate_cache(config_name=None, key=None). It emits index-loaded after a full scan and index-changed with a list of (action, payload) operations after a single-file change. No GTK, so it is testable headless: tests/test_index.py adds 50 tests where that code previously had none.

Changed

  • A plugin adding a workspace page no longer has to clean up after itself. MiAZWorkspace.remove_stack_page removes the child instead of hiding it, so the name is free again and a re-activated plugin just builds a fresh page. add_stack_page replaces any other child holding the same name rather than silently showing it. MiAZNotes dropped both halves of the workaround: the branch that looked for a hidden page by name and adopted it, and the block that hid the page on deactivate. AGENTS.md documented that dance as a rule every page-adding plugin had to follow; it now documents the opposite.

  • MiAZStatus now means one thing. It was doing three jobs through one process-wide flag: "a repository is being switched" (MiAZWorkflow), "a workspace scan is in flight" (MiAZWorkspace.update set it and _apply_parse_results cleared it), and "a plugin is doing bulk work, hold the refreshes" (four plugins). BUSY now only means the first; the scan re-entry guard is a private _scan_in_flight on the workspace, and the plugins take a handle from suspend_updates(). MiAZAddFromDir, MiAZImportFromZip, MiAZOCR and MiAZAutoScan no longer import MiAZStatus at all.

  • Three background workers moved onto run_in_background: the workspace repository scan, the external-libraries venv install and the MiAZInsights page rebuild. Each dropped its own try/except-and-log wrapper.

  • Every remaining background task now runs through run_in_background. The eight raw threads left in the plugins are gone: MiAZAddFromDir (the directory import), MiAZImportFromZip (the ZIP import), MiAZAutoScan (scanner-source detection and the scan itself), MiAZOCR (the OCR run) and MiAZAIAssistant (the suggest call, the chat text extraction and the chat request). Workers that ended in GLib.idle_add(callback, ...) now return their result and let on_done marshal it, so _finish_import, _finish, _build_source_menu, _set_document and _on_answer are ordinary methods again rather than idle callbacks returning False. Two threads stay raw on purpose and now say why in a comment: webserver.py runs a serve loop rather than a task, and util.check_remote_directory_sync starts a thread only to join it with a timeout.

  • MiAZImportFromZip disables the file watcher on the main loop before its worker starts, instead of as the worker's first statement. There was a window where the watcher was still live and reacting to the files being copied.

  • MiAZConfig's used-updated and available-updated now carry the set of keys that changed, instead of nothing. save() diffs what it is about to write against what is on disk, which is the only version that survives set() mutating the cached dict in place. None means the previous contents could not be read, so the receiver must assume everything changed. The workspace uses it to drop one description-cache entry (index.invalidate_cache(config, key)) rather than the whole cache: renaming one country used to make every document in the view rebuild all six of its field descriptions. Handlers that only repopulate a dropdown ignore the payload through the new actions.dropdown_repopulate adapter, so dropdown_populate keeps its signature for its many direct callers. Covered by 9 new tests in tests/test_config.py and 8 in the new tests/test_config_cache_invalidation.py.

  • The workspace filter moved out of the widget into DocumentQuery. _refresh_filter_cache now builds a query (_read_query) instead of copying widget state into eight _cached_* attributes, and _do_filter_view_main is one line: return self._query.matches(item). The five _do_eval_cond_matches_* methods and the self.datetimes memo are gone; date parsing is an lru_cached module function in query.py.

  • The core filter no longer knows MiAZProjectMgt exists. It used to look up plugin-MiAZProjectMgt-dropdown by name and override two of its own conditions when a project was selected. The plugin now registers a query hook that sets ignore_date and ignore_active itself. The register_filter_view hook it already used could not express this, because it ANDs conditions and this one has to relax them.

  • The document parse moved out of MiAZWorkspace into the new index. It used to exist twice inside that 1398-line widget, in _parse_files_worker (full scan) and _build_item_for_file (single file), with the same seven-field loop, cache lookup and config validation written out separately in each. The two had already drifted: the full scan batched cache updates and built a field index, the incremental one wrote the cache directly and refused any document whose name was not fully valid. Both now call MiAZDocumentIndex.build_item, and tests/test_index.py::test_incremental_and_full_scan_agree keeps them honest. workspace.py drops from 1398 to 1211 lines.

  • The workspace no longer writes util._field_index from a re-scan of its own; _apply_parse_results hands over index.field_index(), which the scan has already built.

  • The concept vocabulary the rename dialog completes against (ENV['CACHE']['CONCEPTS']) is now refreshed on single-file changes too, through _publish_concepts. The old incremental path never updated it, so after adding or renaming a document the dialog kept offering concepts from the last full scan.

  • MiAZWorkspace.initialize_caches and _on_application_finished now read and write the index cache instead of a private copy on the widget.

Fixed

  • The .rpm and the .deb pass policy checks and now carry the same payload. The .deb had been shipping 94 .pyc files compiled by the Fedora build host's CPython 3.14, which Debian never loads, and was missing the copyright and changelog.Debian.gz files Policy requires: the cross-distro build path uses plain dpkg-deb, so debhelper never ran. The .rpm shipped /usr/share/doc/miaz/README as a dangling symlink, since %doc stored the repository root symlink rather than its target, and gave its own %changelog a version it disagreed with, because the build counter was written into Version: instead of Release:. RPM artifacts are now named miaz-0.1.50-13.fc44.noarch.rpm, with the release version in Version: and the build counter in Release:, which is what that field is for. Across both packages, 99 library modules carried a shebang while being mode 0644, two of them naming /usr/bin/python, a path Debian does not have, and /usr/bin/miaz was installed group-writable. New miaz.rpmlintrc documents the two rpmlint tags that do not apply to a noarch package with no ELF objects.
  • Every package is built from one export of one commit, so the .rpm and the .deb of a run always declare the same version and carry the same files. They did not before: the .rpm took its sources from git archive HEAD but its spec and version from the working tree, and the .deb took everything from the working tree, so the two could disagree about their own version and about which files they shipped. New scripts/packaging/lib/source_export.sh exports the commit once; create_rpm.sh and create_deb.sh read sources, miaz.spec, debian/* and the version from that export, and build_all.sh hands both formats the same one. It accepts --pushed (fetch, then the last commit pushed to the tracked branch) and --ref REF (any tag, branch or SHA), defaulting to HEAD. The export prints what it is building and warns when uncommitted files are being left out. Two consequences: the package version now comes from the commit rather than the working tree, so a build-counter bump reaches a package once it is committed; and a build no longer writes anything into the repository, since staging and build directories live inside the export.
  • New scripts/checks/verify_packages.sh checks a built .rpm and .deb without installing either. Structure and payload run natively (metadata, dangling symlinks, compiled bytecode, desktop file, AppStream metainfo, GSettings schema, shebangs on non-executable modules, raster images in scalable icon directories, syntax); lintian runs in a podman or docker container when it is not installed; and apt-get install --simulate against debian:stable-slim and ubuntu:24.04 proves the Depends names exist in the target release, which is the one thing static inspection cannot do. It also diffs the two payloads against each other, which is what caught the bytecode only the .deb carried. build_all.sh runs it with --no-container and fails the build on a regression.
  • The rename dialog's Rename button is insensitive while the fields cannot make a valid filename, instead of looking clickable and doing nothing. _on_rename_response refused the rename and focused the offending field, which is invisible when the field is already on screen, so the button read as broken. MiAZRenameDialog emits a new fields-changed signal from _on_changed_entry, and the dialog enables the response from is_valid(). Date, Country, Sent by, Concept and Sent to are the blocking fields; Group and Purpose stay advisory (a warning in the live preview). A document whose stored values are not in the repository configuration opens with those dropdowns on "Any", which is now visible in the button rather than only in the row colours.
  • Dialog bodies no longer open with all their text highlighted. The body label of every dialog is selectable so an error message or a path can be copied, and a selectable Gtk.Label selects everything the first time it takes the focus, which is what the dialog does while opening: the rename confirmation came up with "You are about to rename this document. Are you sure?" fully selected. A focus controller drops that first selection, one idle later so it lands after the select-all, and removes itself; selecting by hand is untouched.
  • A failed background operation no longer freezes the workspace for the rest of the session. MiAZImportFromZip, MiAZAutoScan and MiAZOCR each take an update-gate handle before starting their worker and release it in the callback that reports the result. An exception outside the worker's own try block killed the thread silently, so the handle was never released, the view stopped refreshing and every later update was swallowed with nothing in the log. MiAZImportFromZip had one waiting: get_temp_dir() ran after the watcher was disabled but before the try. Each of them now passes an on_error that releases the gate, re-enables the watcher and reports the failure.
  • MiAZAddFromDir reports a directory import that stopped early instead of ending in silence.
  • MiAZAutoScan still installs its Add-menu entry when scanner detection fails. Detection ran in a thread whose only job was to build that menu, so an exception left the plugin with no entry at all: no way to scan and no hint that anything had gone wrong. A failure now installs the plain entry, which reports the real problem when used.
  • The AI assistant's "Suggest with AI" button no longer stays greyed out reading "Thinking…" after an unexpected failure, and the chat no longer waits forever for an answer that will not arrive. Both had error handling that covered the provider call but not the code around it.
  • The "Since last year" date filter covered the wrong period. It resolved through util.since_date_this_year, which returns 1 January of the current year, so the label said "last year" while the range was the calendar year to date: seven months on 7 August, and two days on 2 January. It now uses since_date_last_n_months(now, 12), the last twelve months, consistent with its "Since last 3 months" and "Since last 6 months" neighbours. The same window was wrong in MiAZInsights, which mirrors the same preset list, and is fixed there too so the two views agree.
  • Lint is clean again: ruff check MiAZ data/resources/plugins tests reported three unused imports. timedelta in backend/util.py and a stray pytest in tests/test_boundaries.py were removed. The third, make_usage in the AI plugin's suggestion.py, is a deliberate re-export that tests/test_ai_plugin.py asserts on, so it is now declared in __all__ instead: applying the fix ruff suggested would have deleted it and broken that test.
  • The MiAZFullscreen button now comes back after the plugin is disabled and re-enabled. do_deactivate detached the button from the header bar but left it registered under headerbar-togglebutton-fullscreen, so the next activation looked the key up, found the detached button, took that as "already set up" and skipped the whole block. The button was gone for the rest of the session. Widget keys are now unregistered along with the widget.
  • Disabling and re-enabling MiAZNotes no longer leaves the "All notes" page wired to the previous plugin instance. The page was kept in the stack and re-adopted by the new instance, which reassigned store, backup and the two compute callbacks but not on_open_document. That callback stayed bound to the deactivated instance, so opening a note from the page called into a plugin that was no longer running, and the dead instance could never be collected. The page is now rebuilt, so every callback belongs to the live plugin.
  • Workspace pages of an uninstalled plugin no longer stay in the Adw.ViewStack for the rest of the session as hidden children.
  • Two overlapping background operations no longer let the first one to finish re-enable workspace refreshes for both. Every setter of the old MiAZStatus.BUSY flag reset it unconditionally, so starting an OCR run while an AutoScan import was still going meant the remaining work refreshed the view on every file, which is what the flag existed to prevent. The reference-counted gate stays shut until the last holder releases. MiAZAutoScan could also release twice (its finally block and _on_scan_error both ran on a failed scan); the handle makes the second release a no-op instead of an unbalanced reset.
  • Switching to a repository no longer reads configuration left behind by a previous one. MiAZConfig.cache was a class attribute, shared by every config instance in the process and keyed by absolute filepath. Sharing was deliberate (SentBy, SentTo and Person all read people-available.json and must agree), but it tied the cache lifetime to the process instead of the repository: repository.load() built eight new config objects per switch while the old entries stayed, so a repository edited outside MiAZ, or simply switched away from and back to, was read from the stale copy. The cache now belongs to the MiAZConfigStore of one repository and is emptied when that store is disposed. Two of the new tests fail against the previous code.
  • MiAZConfig.set() no longer writes its cache bookkeeping to the wrong key. It called save() with no filepath; save_data resolved the default and wrote the correct file, but the cache invalidation and the signal check both saw the empty string, so self.cache[''] was marked dirty, the real entry was left looking clean, and used-updated never fired. It worked only because load() returns the cached dict by reference and set() mutates that same object: any caller saving a freshly built dict would have read stale config afterwards. save() now resolves the default before doing either.
  • MiAZConfigApp.save() now invalidates the cache. It overrides save() to emit repo-settings-updated-app instead of the base signals and skipped the cache bookkeeping entirely. This is the config that records the active repository, so it sits on the switch path.
  • Deleting a document from the rename dialog now goes through util.filename_delete instead of calling os.unlink directly, so the filename-deleted signal fires. Without it the workspace and the new document index both kept an entry for a file that was already gone, until the next full re-scan. Found by the new boundary test, which is the only violation of that rule in the tree.
  • A failed workspace scan no longer leaves the application stuck. update() sets the status to BUSY and only _apply_parse_results cleared it, so an exception in the worker thread left the app busy for good: every later update logged "App is busy. Workspace update deferred" and rescheduled itself, forever, with the thread already dead and nothing in the log to say why. The scan now runs through run_in_background with an on_error that logs the failure and releases the status.
  • A deleted file that was not in the view no longer decrements the document counter. The old incremental handler removed unconditionally, so the count could drift below the real number until the next full scan.

[0.1.40] - 2026-08-05

Added

  • Controlled-vocabulary field descriptions are now localized to the user's language. Country, Group and Purpose descriptions previously always showed in English (they ship as English JSON in data/resources/conf/ and are copied into each repository, bypassing gettext). A new util.humanize_value(gtype_name, description) translates them at display time: Country through the standard iso-codes iso_3166-1 gettext domain (so every language iso-codes ships is covered with no per-language work, e.g. Spain to España), and Group/Purpose through the app catalog. The stored value and the on-disk filename keep the original code, only the shown label changes, and any user-customized or user-typed value (people, concepts) passes through unchanged. The helper is called from every display surface: the configuration views, the sidebar and workspace filter dropdowns (dropdown_populate), the workspace columns, and the rename "Suggest metadata" view. The default Group and Purpose labels are listed in the new MiAZ/backend/vocabulary.py so they are extracted into the catalog for translation (kept in sync with the JSON by tests/test_vocabulary.py). The iso_3166-1 domain is bound at startup; iso-codes was added as a dependency (deb and rpm; the GNOME Flatpak runtime already includes it).
  • Translation infrastructure refreshed and seeded for new languages. po/POTFILES was stale (it listed removed plugins and the deleted widgets, and omitted every plugin added since, including both AI plugins, Notes, Newspaper, OCR and others); it now lists all 88 source files that contain translatable strings. The template po/miaz.pot was regenerated from it (825 messages) and merged into po/es_ES.po, which surfaces the real translation debt that the stale template was hiding. French (po/fr.po) and German (po/de.po) were seeded from the template and added to po/LINGUAS; all three locales compile. The actual message translation is left to native reviewers now that the extraction is correct.
  • AI plugin API keys are now stored in the system keyring instead of plaintext JSON. A new backend service secrets (MiAZ/backend/secrets.py, MiAZSecretStore) stores secrets through a cascade: libsecret (the Secret Service) first, then the python keyring library, and only if neither is available does the key stay in the plugin's JSON config (with a warning toast). Keys already saved in plaintext are migrated into the secure store on plugin load and stripped from the JSON file. Both AI plugins (MiAZAIChat, MiAZAIAssistant) store per provider under an account like MiAZAIChat/claude; the API-key field now saves on apply rather than on every keystroke. As defence in depth, MiAZPlugin.set_config_key no longer logs config values (only the key name). Flatpak manifests gained --talk-name=org.freedesktop.secrets; packaging gained gir1.2-secret-1 (deb) / libsecret (rpm) with python3-keyring recommended, and a keyring optional dependency in pyproject.toml. Covered by tests/test_secrets.py (9 tests, injected fake backends).
  • Per-type configuration import and export. Each configuration view (Countries, Groups, Purposes, Sender, Recipient, Repositories, Plugins) gained an Export/Import menu button on the available-items toolbar (MiAZConfigView._add_config_menubutton, frontend/desktop/widgets/configview.py). Export writes the available pool of that type to a JSON file through Gtk.FileDialog; import reads a JSON file and merges it into the pool (existing entries are kept), then refreshes the view through the available-updated signal. This replaces the previous "not implemented yet" placeholders; the orphaned, never-wired import_config/export_config stubs in services/actions.py were removed in favour of the config-view implementation, which has direct access to the configuration object.
  • CONTRIBUTING.md is now tracked in the repository.
  • Single version sync script scripts/devel/sync_versions.sh. It reads the base release version from meson.build (the single source of truth, build metadata stripped) and propagates it to every packaging file: it overwrites the single version fields in pyproject.toml and miaz.spec idempotently, and prepends a new entry to debian/changelog, the spec %changelog, and the AppStream <releases> list only when the release version actually changed (so plain build-counter bumps touch nothing and prior release history is preserved). The prepended note is a placeholder pointing to CHANGELOG.md. scripts/devel/build.sh now runs it right after bumping the meson build counter, so every build keeps the metadata consistent.
  • Restored unit tests for the mass-rename concept transforms (tests/test_massrename.py, 42 tests). Coverage was lost when MiAZMassRename moved from a plugin (with a standalone concept_ops module) to a core service and the old tests/test_massrename_concept.py was deleted. The new tests import the pure module-level functions from MiAZ.frontend.desktop.services.massrename (parse_positions, keep_tokens, remove_tokens, add_prefix, add_suffix, find_replace, change_case, set_value, and the apply_concept_op dispatcher) and cover the renamed dispatcher plus new branches (empty-concept prefix/suffix, title case, blank separator fallback, unknown op). They set the gi versions before import, the same way tests/test_util.py does, so they run headless in CI.
  • Optional external libraries. Plugins that need third-party Python libraries (for example the AI provider SDKs) can now have them installed into a private per-user virtualenv at ~/.MiAZ/opt/venv, never into the system Python. Each plugin declares its dependencies in a requirements.txt. After the first repository, MiAZ offers to download the libraries once; the same action lives in Settings under "External libraries", and enabling a plugin installs its libraries when the feature is on. When a library is missing, the AI error dialog and the chat offer an "Enable external libraries…" action.
  • Plugin load failures are now visible in the app instead of only the log. When an enabled plugin fails to load, a startup toast reports how many failed and points to Settings > Plugins, where a banner names each failed plugin and its reason. The "Plugins activated: N of M" count now excludes plugins that failed to load. The banner connection follows the same map/unmap pattern as the other configuration views, so reopening Settings does not leak signal handlers. Covered by tests/test_pluginsystem.py.
  • Plugins can contribute tabs to the single-document rename dialog. The dialog is now an Adw.ViewStack whose first page, Fields, holds the seven filename fields; the current and new filename move out of that list into a footer that stays visible from every tab. A new core service document-tabs (frontend/desktop/services/doctabs.py) holds the registrations, and MiAZPlugin gained register_document_tab(name, title, factory, icon_name=None, weight=100) and unregister_document_tabs(), alongside install_menu_entry and add_workspace_page. A registry rather than a signal, because the dialog is built fresh on every rename: plugins no longer have to track handler ids to avoid contributing the same thing twice. Tabs are ordered by (weight, title) with Fields always first; registering a name twice replaces it, and unload_plugin drops a plugin's tabs even if it forgets to. The tab widget answers a duck-typed contract: set_document(doc_id) when the dialog opens, apply(old_id, new_id) after the rename succeeded, optional is_valid() to veto and optional discard(). Tabs hold their edits: nothing is written while the user works, so Cancel discards tab edits the same way it discards field changes, and by the time apply runs the filename-renamed handlers have already moved plugin data to the new name. Every call is wrapped in try/except, so a failing tab logs and never blocks a rename. With no tab registered the view switcher is not installed and the dialog looks exactly as before. MiAZWindowDialog gained set_title_widget() for the switcher, and the rename-dialog-built signal stays as it is for header-bar additions such as the AI "Suggest" button. Covered by tests/test_doctabs.py (7 tests).
  • MiAZProjectMgt shows a Projects tab in the rename dialog: one check row per enabled project, ticked for the projects the document belongs to. Applying clears the document from every project and re-adds the ticked ones, falling back to the default bucket when none is ticked, mirroring what the workspace assign and unassign actions do.
  • MiAZPeriodicity shows a Periodicity tab in the rename dialog: one dropdown over the enabled vocabulary, showing the document's current value and writing it through the same code path as the workspace action.
  • Every plugin now has an icon, and the rename dialog tabs wear it. A plugin ships icon.svg or icon.png next to its module; MiAZIconManager.register_file_icon(name, filepath) exports it into the new ~/.MiAZ/opt/icons (ENV['LPATH']['ICONS'], added to the icon theme search path at startup) under the unique name miaz-plugin-<module>, because widgets take icon names and the theme resolves names rather than paths. MiAZPlugin.get_icon_name() returns that name, or the bundled generic plugin icon when the plugin ships none, and never an empty value. register_document_tab uses it when the caller passes no icon_name, so a plugin gets a recognisable tab without doing anything about it. The Fields tab carries the rename icon so the switcher does not mix bare labels with icons.
  • New util.filename_rename_needed(source, target, upper=True): whether a rename would really move the file, comparing the target after the same uppercasing filename_rename applies. filename_rename returns False both when the target already exists and when there is nothing to do, and callers with work to do alongside the rename have to tell those apart. Both now share one _rename_target helper so the normalization cannot drift.

Changed

  • Renamed the MiAZYearReport plugin to MiAZInsights. The name stopped matching the plugin: a year is one scope out of eleven now, since _periods() builds nine rolling windows (this month through ten years ago) on top of the per-year views and the all-years overview, and "report" suggests a document you print once when the page is interactive (hash routing, period selector, year combo, clickable heatmap and trend, theme toggle, foldable tables). The new name says what the page delivers and what the Workspace columns cannot: busiest month, longest streak, rank movers, where documents come from. Renamed the plugin directory, MiAZInsights.py, the insights/ support package, miazinsights.plugin and tests/test_insights.py; Module, Name, Description, PLUGIN_DIR_NAME, the class and its __gtype_name__ followed. The page heading, the browser tab title and the menu entry now read "Insights" and "Open insights"; the dead title_year label ("Year report {year}", never rendered since the single-page redesign) was dropped. po/POTFILES points at the new paths and the two renamed messages are marked fuzzy in po/es_ES.po for review, while the obsolete "Printable yearly summary report" description was removed (849 to 848 messages). PLUGIN_DIR_NAME is the WWW page key, so the Browser dropdown entry changes and any ~/.MiAZ/var/www/html/MiAZYearReport left from an earlier build is orphaned; it is removed on upgrade here but a manual rm -rf clears it elsewhere. Per-repository plugins-used.json and plugins-available.json are keyed by plugin name, so the plugin comes back disabled and has to be enabled once per repository.
  • MiAZInsights gained a period selector and a world map. The year pills became two dropdowns side by side: Year (All years plus every year in the repository) and Period, which offers the same windows as the workspace date filter (this month, since past month, since last 3 and 6 months, since last year, since two, three, five and ten years ago) with the same titles and the same lower limits, computed from the same util.since_date_* helpers. Both select a date range, so choosing in one clears the other, and the selection still lives in location.hash. A period view is built like a year view: hero total with the change against the window of equal length right before it, a timeline (monthly up to two years, yearly beyond that, so a ten year window is eleven columns and not a hundred and twenty), ranked senders, concepts and purposes. A new Where it comes from section renders in every view, including All years: a world map with one path per country shaded on the same sequential blue scale as the activity heatmap, with the ranked country list beside it. Countries too small to see at this scale (Luxembourg, Malta, the island states) carry a marker circle that switches on when they hold documents. The outlines ship as static/worldmap.svg (120 KB, 174 countries, Natural Earth 1:110m public domain data projected with Robinson), generated by the new scripts/devel/gen_worldmap.py and inlined into the page at build time, so the report still fetches nothing and still works offline over file://. The map is marked aria-hidden because the country list next to it carries the same values as text. Covered by 8 more tests in tests/test_insights.py (range aggregation, bucket switching, previous window, period payload shape, country counts), 30 in total.
  • MiAZInsights is now a single page with a selectable year row and an all-years overview. It used to publish one HTML file per year plus an index.html copy of the most recent one, with a full page reload on every year change and no view of the archive as a whole. It now publishes one self-contained index.html that carries every aggregated number as an embedded JSON payload and renders views from it: clicking a year in the pill row (or pressing Left/Right/Home/End) swaps the content in place, and the selection is mirrored in location.hash so the Browser tab's Back button walks the year history and a refresh restores the view. The new All years view leads with lifetime totals (documents, years covered, span, distinct senders, purposes, concepts, groups, countries), then a per-year trend with year-over-year deltas, a years-by-months activity heatmap, rank movers (all-time leading senders and purposes with how their rank moved in the latest year: new, up, down, gone), and firsts and lasts (first and latest document with their sender, busiest and quietest year, longest run of consecutive active months, senders first seen or gone silent in the latest year). The per-year view keeps its four stat cards and gains a twelve-column month chart, a share-of-year percentage per purpose with the one-off tail folded into "Other", and a "New this year" block for senders and purposes. Charts follow the MiAZ data-viz palette in selected light and dark step sets, switched by prefers-color-scheme with an in-page toggle that overrides it (WebKitGTK does not reliably pass the app theme through); every chart has hover tooltips and a foldable data table, and printing opens those tables, drops the chrome and renders light. The plugin was split into MiAZInsights.py (lifecycle), insights/aggregate.py (pure arithmetic), insights/render.py (payload and page) and static/report.css + static/report.js (inlined at build time), covered by tests/test_insights.py (22 tests). Still no stat call per file: everything comes from the filenames, and document count remains the proxy for "biggest purpose" since the seven-field scheme carries no amount.
  • MiAZNotes marks documents that have notes with a dedicated leftmost workspace column (a pin icon plus the note count) instead of a CSS row highlight. The column is added to the workspace view at runtime when the plugin activates and removed when it deactivates, so it only exists while MiAZNotes is enabled. This replaces the old .miaz-has-notes highlight, which embedded a hardcoded absolute path to the pin SVG and so broke on any installed system; the new cell uses a theme icon name.
  • Moved MiAZImportDoc ("Add new document(s)") from a togglable plugin to a core service (services/importdoc.py, service importdoc, following the same pattern as MiAZMassRename). Every repository needs a way to add its first document, so that action should not live behind an optional, disableable plugin; a fresh repository previously had to hardcode it into its default plugins-used.json to avoid shipping with no way to add documents at all, which is no longer needed now that the feature cannot be disabled. The headerbar "Add" menu (headerbar-add-menu) now always shows the core action first, followed by any loaded Import-subcategory plugin (MiAZAddFromDir, MiAZImportFromZip, MiAZImportFromScan), and the Add button is now always visible instead of only when an Import plugin happened to be enabled.
  • The workspace no longer opens on an empty view when the default date filter has no documents. Right after a new month starts, "This month" is usually empty; the workspace now selects the nearest date filter that actually contains documents (walking to wider windows: "Since past month", "Since last 3 months", and so on, falling back to "All documents"). It runs only as the initial default, at load, repository switch, and the day/month rollover, and never overrides a date filter you selected by hand. The chooser (pick_date_preset in widgets/workspace.py) is covered by tests/test_workspace_datepick.py.
  • The rename dialog is now keyboard-friendly. It focuses the first field that needs attention when it opens, the date is an editable YYYYMMDD entry (the calendar remains as an assist), Ctrl+Enter applies from any field and Esc cancels, and in the concept entry Enter picks the highlighted autocomplete while the popover is open. Applying now refuses to build an invalid filename: when a required field (date, country, sent by, concept, sent to) is missing or invalid the dialog stays open and focuses the field to fix.
  • Merged the MiAZAIChat plugin into MiAZAIAssistant. There is now a single AI plugin that offers both features: suggesting the seven filename fields from a document (the Suggest filename… menu entry and the rename-dialog button) and chatting about the selected document (the Chat with document… menu entry). Both share one provider layer and one settings dialog, so API keys and the active provider are configured once. Internally the miazaic package was folded into miazai: every provider (Claude, OpenAI, Gemini, Ollama) now implements both suggest() and chat() on a shared base, make_usage has a single definition in miazai/usage.py, and the chat dialog moved to miazai/ui/chat.py. No configuration is migrated, so anyone who had configured the old MiAZAIChat plugin re-enters that provider's API key once. The aggregated data/resources/plugins-requirements.json no longer lists miazaichat. Covered by tests/test_ai_plugin.py.
  • Three workspace-view efficiency fixes from a performance review (responses/2026-08-04-workspace-view-performance-analysis.md), none of them behavior changes. (1) The workspace Type column no longer stats the file on disk to pick its icon: Gtk.ColumnView rebinds a cell every time its row scrolls back into view, so the old MiAZIconManager.get_mimetype_icon (os.path.exists + Gio.File.query_info per bind) repeated that disk I/O on every scroll. It is replaced by get_mimetype_icon_for_extension, which resolves the icon from the extension alone via Gio.content_type_guess and caches it, no filesystem access at all. (2) _parse_files_worker built the field-value index (used by "how many documents use this Country/Group/…") in a second full pass over the file list that recomputed util.get_fields() for every file from scratch; it is now folded into the existing parse loop, which already has those fields in hand. (3) The workspace filter's project-dropdown bypass check (used when MiAZProjectMgt has a project selected) looked up the dropdown widget and its selection on every item on every filter pass; it is now resolved once per pass in _refresh_filter_cache, alongside the other cached filter inputs, and read from self._cached_project_bypass.
  • Four more workspace-scan efficiency fixes, benchmarked against a real 1231-document repository. The biggest one: Country/Group/Purpose/SentBy/SentTo display labels are now cached per (field, raw value) in self.cache, the same way the Date label already was; only humanize_value() (a gettext lookup) was uncached, and re-running it for every field of every document on every scan measured at 122.78ms for this repository, dropping to 0.32ms once cached (values repeat heavily across real documents, e.g. 7 distinct countries across 1231 files). The cache is invalidated by the same used-updated signal that already resets it, so edited vocabulary still shows up correctly. Also: util.get_files() now lists the repository with os.scandir() instead of glob.glob() + a separate os.path.isfile() stat per entry (7.66ms to 1.53ms for 1231 files); the parse loop's own fields = util.get_fields(filename) is reused instead of calling filename_validate() right after (which re-parsed the same filename from scratch); and the workspace's default Date column sort uses a dedicated comparator with no .upper() call, since a YYYYMMDD value has no case to fold, while every other sorted column (group, purpose, sender/recipient labels, concept) keeps its case-insensitive compare since those can hold real mixed-case text. Measured together, this repository's ~224ms workspace-update time is expected to drop to roughly 90-100ms; the remaining time is the MiAZItem GObject construction and GTK's own model/view work, both inherent to the Gio.ListStore architecture.
  • The "Review" toggle triggered two full workspace rescans per click instead of one. show_pending_documents calls sidebar.clear_filters(), which itself calls workspace.update(), and then separately reselects the Date dropdown to "All documents", whose selection signal independently calls update() again. The second reselection now blocks that signal (the same handler_block/handler_unblock pattern already used in _update_dropdown_date and _auto_select_date_preset for the same purpose), so toggling Review still shows every date range, but scans the repository once instead of twice.
  • MiAZNotes' "All Notes" view now defaults its status filter to "All statuses" instead of "In Progress", so it opens showing every note rather than hiding done/planned ones by default.
  • The "Workspace updated in {dt}s" debug log now includes the document count: "Workspace updated in {dt}s ({n} documents displayed)", n being the count after the active filters are applied, not the repository total. The log line moved from _apply_parse_results (end of the background parse, before the store is spliced or filtered) to _idle_view_update (after view.update() applies the splice, when self._num_displayed_items is the real post-filter count), so the timing now also covers the store splice and filter pass, not just the parse.
  • Incremental workspace updates for large repositories. A single file change (add, delete, rename) no longer re-scans and re-parses the whole repository; it updates just the affected row. The watcher now forwards the changed path and event through a new repository-changed(path, other, event) signal (coalescing a burst to one settled event per path, and falling back to a full repository-updated when more than 25 files change at once). The workspace applies the change with the columnview's update_incremental primitive and a _build_item_for_file helper that mirrors the full parse. The existing full re-scan (repository-updated) is kept as the safety net for the Notes plugin and for anything the incremental path does not confidently handle (pending or invalid names, unknown events); a skip flag suppresses that re-scan only when the per-file update fully applied the change. Because the fallback does a complete splice-replace, an imperfect incremental update can never corrupt the list, only cost a redundant re-scan. Covered by tests/test_watcher.py (7 tests). This turns the common single-document case from O(N) to roughly O(1); bulk changes still take the full path.
  • GNOME HIG polish (P1 batch). The sidebar's "Search in all fields" and concept search now use Gtk.SearchEntry instead of a bare Gtk.Entry, so they get the search icon, a clear button and Escape-to-clear (the config selector already used Gtk.SearchEntry). Label typography in services/factory.py moved from manual Pango markup to theme-correct CSS classes (heading, caption, dim-label) and, importantly, the dropdown and column-view cell labels now use set_text(item.title) instead of set_markup, which removes a real markup-injection risk for values containing &, < or > (a company name, a concept). The "No documents found" status page dropped its redundant <big> markup wrapper. The keyboard-shortcuts UI was migrated from the deprecated Gtk.ShortcutsWindow (deprecated since GTK 4.18) to Adw.ShortcutsDialog (libadwaita 1.8+, present at the GNOME 50 floor), which also removed the inline builder XML in services/actions.py and the now-unused services/help.py.
  • Accessibility: the icon-only buttons in the configuration selector (frontend/desktop/widgets/selector.py) now have tooltips: Add, Remove and Edit on the available side, Edit on the used side, and Enable/Disable on the transfer controls. The Enable/Disable tooltips were also hardcoded English strings ('enable'/'disable'); they are now translatable. A broader accessibility audit stays on the 0.2.x backlog.
  • CI (.github/workflows/ci.yml): the test job now runs a Python matrix (3.11 and 3.12), lints tests/ in addition to MiAZ and the plugins, and reports coverage (pytest-cov, informational). 3.9/3.10 compatibility is enforced statically by ruff's target-version = "py39", since the system PyGObject bindings follow the runner's Python and cannot be matrix-tested against pip.
  • Clarified the backend architecture rule in CLAUDE.md and AGENTS.md: the backend forbids GTK/Adw/Gdk widget imports, but GObject/GLib/Gio are allowed and used on purpose, since the backend exposes its events through GObject signals (the backend/frontend contract). The old "zero GTK imports" wording read as a violation when it was the intended design.
  • Extended .gitignore to cover build artifacts and local working files (AppDir/, builddir*/, *.AppImage, *.deb, *.flatpak, *.tar.gz, *.snap, repo/, logs/, linuxdeploy*) and session files (commit.txt, responses/, .claude/, .opencode/).
  • The Flatpak manifest flatpak/io.github.t00m.MiAZ.json now builds the miaz module from local sources ("type": "dir", "path": "..") instead of cloning a pinned github.com/t00m/MiAZ.git branch (0.1.26). The build now uses whatever version meson.build declares, so there is no version string to keep in sync in the manifest. Both Flatpak manifests (.json and .local.json) now build from the local tree.

Deprecated

  • Flatpak packaging is deprecated on purpose and no longer built by default. A Flatpak runs sandboxed on org.gnome.Platform, which cannot see or run the host command line tools that plugins depend on (ocrmypdf for OCR, scanimage for the scanner), so those features never work in a Flatpak build and users saw "OCR tools not installed" even with the tool present on the host. MiAZ is a native desktop app; use the deb, rpm or AppImage package instead. The Flatpak build code is kept for reference and is gated behind MIAZ_ALLOW_FLATPAK=1 in build_all.sh and create_flatpak.sh.

Removed

  • Dropped the MiAZNewspaper plugin and everything tied to it. It rendered the documents visible in the Workspace as a "MiAZ Times" broadsheet published to the Browser page, but every sentence on that page was generated from the seven filename fields the Workspace columns already show, so it produced no information the user did not have. Its costs were real: 36 translatable messages (out of 885) in the hardest register to translate, prose hardcoded to the default purpose codes (INV, REQ, STM, INF, CON, TKT) so any renamed purpose fell back to a generic sentence, a full rmtree plus a recopy of 244 KB of web fonts on every filter and sort change, and 275 lines of templates/*.html design mockups that no code ever read. It also declared Dependencies: HelloWorld, a leftover from templating off the demo plugin, which silently enabled HelloWorld whenever the newspaper was enabled. Removed: data/resources/plugins/MiAZNewspaper/, its po/POTFILES entry, and its 36 messages from po/miaz.pot, po/es_ES.po, po/de.po and po/fr.po (885 to 849 messages, all four in sync, all compile). MiAZInsights (formerly MiAZYearReport) is now the single reference example for the WWW-publish + Browser-page pattern in AGENTS.md, and it earns the pattern by aggregating (counts, heatmap, rank movers, country map) instead of restating. Bundled plugin count is now 19. Historical release notes in CHANGELOG.md, debian/changelog and metainfo.xml.in still name the plugin on purpose; those record what shipped at the time.
  • Removed the per-repository change journal (MiAZHistory, backend/history.py, service history). It appended every add, rename and delete to <repo>/.history/<YYYYMM>.jsonl, but nothing read it back and the feature was unused, so it was dropped along with its app.py service registration and its tests. MiAZ is a curated tool for a small set of documents, not an archive of record, so change tracking, undo and recovery are not part of its scope. Git-based repository tracking may return later as an optional MiAZGit plugin. The filename-imported signal and the origin provenance argument on util.filename_import, which existed only to feed this journal, were removed as well: filename_import(source, target) now just copies and emits filename-added, and the ZIP and scan importers no longer build provenance objects.
  • Removed two unused GSettings keys, sidebar-width and last-repository, from data/io.github.t00m.MiAZ.gschema.xml. Nothing read or wrote them. The sidebar lives in an Adw.OverlaySplitView (fixed 300-360 px range, no user-draggable resize), so there is no sidebar width to persist; and the active repository is already restored from the App JSON config current key, so a second GSettings copy would only add a competing source of truth. Removed now, before 0.2 freezes the schema. The Countries configuration keeps its add/remove/edit buttons hidden by design (countries are a fixed ISO 3166-1 vocabulary with bundled flag icons); the stale FIXME there was replaced with a rationale comment.
  • Dropped the Snap and Windows packaging paths for the 0.2 release. Removed snap/snapcraft.yaml (stale at 0.1.27, never tested) and scripts/packaging/win/, plus their sections and the snap PATH shim in scripts/packaging/build_all.sh. The officially supported formats are now deb, rpm, flatpak and AppImage. Both are recoverable from git history if revived later.
  • Removed dead and obsolete files: unused widgets MiAZ/frontend/desktop/widgets/about.py (the About dialog uses Adw.AboutDialog in services/actions.py) and widgets/searchbar.py (never imported); obsolete packaging scripts scripts/packaging/deb/create_deb_last.sh (setup.py + stdeb) and scripts/packaging/AppImage/miaz_appimage_repackage.sh (hardcoded 0.0.5), plus the now-unused root stdeb.cfg; the deprecated scripts/translations/ directory (superseded by scripts/i18n/); and tracked junk (Changelog.md, and untracked CHANGELOG.md.backup, s1.txt, s2.txt, damnsign.txt, root build.sh).
  • Removed an unused gi.repository.GObject import in data/resources/plugins/MiAZAIAssistant/miazaiassistant.py (was failing the CI ruff step).

Fixed

  • Plugin icons never showed up anywhere, because MiAZPlugin.get_source_dir() looked for a folder named after the plugin's Module (projmgt) while plugin folders are named after its Name (MiAZProjectMgt). For most bundled plugins it therefore returned None, so get_icon_path() found nothing and every caller silently fell back to no icon, including the sidebar filter rows in MiAZProjectMgt and MiAZPeriodicity. It now tries Name first and Module second, in the system directory then the user one.
  • Opening the rename dialog only to set a plugin value (a project, a periodicity) and pressing Rename showed "Another document with the same name already exists" and lost the edit. With no filename field touched there is nothing to rename, and util.filename_rename returns False both for "nothing to do" and for "the target already exists", so the dialog reported a name clash and never committed the tab edits. It now checks whether a rename is due first: when it is not, it skips the confirmation, writes the tab edits against the unchanged name, shows a toast and closes. When a rename is due, nothing changes.
  • The "Suggest with AI" button from MiAZAIAssistant could appear several times in the rename dialog. inject_suggest_button connected a new rename-dialog-built handler on every call but tracked only the latest handler id, so any repeated call leaked the earlier handler and stacked another button on every rename. The injection is now idempotent: it disconnects any previously installed handler before connecting a new one, and clears its tracking slot on removal, so exactly one button is installed no matter how many times it runs.
  • Plugins that rely on system command line tools no longer fail with a "tools not installed" error when MiAZ is launched from the GNOME desktop icon rather than a terminal. A desktop launcher can start the app with an empty or stripped PATH, so shutil.which and subprocess found nothing (MiAZOCR reported ocrmypdf missing, MiAZAutoScan reported scanimage missing) even though the tools live in /usr/bin; running from a shell worked because the shell PATH is rich. MiAZ now appends the standard system tool directories (/usr/bin, /usr/local/bin, ~/.local/bin, and the sbin variants) to PATH at startup if they are missing, so tool detection and execution behave the same regardless of how the app was started.
  • Dialog body text is now selectable and left-aligned. The Adw.AlertDialog body label was read-only and centred, so a user could not copy the command or message shown in a dialog such as "OCR tools not installed". This applies to every error, warning, info and question dialog.
  • The workspace date filters "Since past month", "Since last 3 months" and "Since last 6 months" excluded documents from the earliest month they should cover. since_date_last_n_months subtracted a fixed 30 days per month and forced the day to the 1st, which drifts at month boundaries: on the 31st of a month, 30 days back stays in the same month, so "Since past month" on Jul 31 started on Jul 1 and hid all of June (and the 3 and 6 month options were each off by a month). It now steps back whole calendar months. Covered by new tests in tests/test_util.py.
  • The workspace date filter no longer hides documents dated today when MiAZ has been left running past midnight. The relative date presets (This month, Future, and so on) baked in the day the workspace was first loaded and were never refreshed, so once the calendar day rolled over a document dated today fell outside every today-bounded range and only appeared under "Future" (which had been defined as the old day plus one). update() now rebuilds the presets with the current day whenever it detects the day changed, preserving the selected preset by title (its key changes daily, so it cannot be matched by key). Same-day updates skip the rebuild, so there is no added cost in normal use.
  • Disabling or uninstalling a plugin that publishes to the MiAZ Browser now removes its web content. Web cleanup moved from each plugin's do_deactivate into the plugin manager (unload_plugin calls a new _remove_plugin_www), which deletes LPATH/WWW/<plugin folder basename> for any plugin, bundled or user-space, on disable or uninstall. Previously only MiAZNewspaper and MiAZInsights cleaned up after themselves, so user plugins such as KB4MiAZ and MiAZdeas left their published directories behind and they kept showing in the Browser. The duplicated rmtree in the two bundled plugins was dropped. A plugin that publishes under a name different from its folder is not covered centrally and must still clean up in its own do_deactivate.
  • The MiAZNotes plugin failed to load from the rpm (and any git archive based package) with ModuleNotFoundError: No module named 'lib'. The plugin keeps its own code in a bundled lib/ subpackage (data/resources/plugins/MiAZNotes/lib/), but .gitignore had an unanchored lib/ rule (from the standard Python template, meant for a build directory at the repo root) that matched that path at any depth. The subpackage was therefore untracked and excluded from the git archive HEAD source tarball, so the installed plugin had notes.py but no lib/. Anchored the rule to /lib/ and /lib64/ so it only ignores a repo-root build directory, not nested plugin source. The MiAZNotes/lib/ files must now be added to git so they ship. This was a packaging problem, not a missing third-party dependency.
  • Config and repository JSON is now written atomically. MiAZUtil.json_save and the repository bootstrap (repository.py init / _init_default_plugins) share a new module-level atomic_json_save (MiAZ/backend/util.py) that writes to a temporary file in the same directory, fsyncs it, then os.replace()s it onto the target. A crash mid-write can no longer leave a half-written repo.json, plugins-used.json, or config file.
  • The disaster-recovery restore paths (MiAZ/backend/dr.py) no longer risk masking the real error or crashing during rollback. restore_config and restore_repository define the .old sidecar path before the try, so a failure in unzip (before anything is moved) rolls back cleanly instead of raising NameError. The rollback is wrapped so a rollback failure is logged rather than replacing the original exception, and the original error is logged before re-raising. Covered by the new tests/test_dr.py (8 tests: backup/restore round-trips plus both rollback paths).
  • MiAZUtil.filename_rename no longer silently swallows the two skip cases. A rename whose target already exists now logs a warning (the caller already surfaces the skip; mass rename counts it in a toast), and a no-op rename where source equals target logs at debug. Removed the stale # FIXME markers; the Windows-path doubt is resolved as "Linux-only for 0.2" with a comment.
  • Version numbers now agree across every static config file. meson.build is the single source of truth (0.1.28+build.29). miaz.spec (Version and %changelog), pyproject.toml (version), debian/changelog, and the AppStream <releases> list in data/io.github.t00m.MiAZ.metainfo.xml.in were stale (0.1.26 / 0.1.1); all now read the release version 0.1.28. The +build.NN suffix is left to meson.build alone, since scripts/devel/increase_meson_version.sh auto-increments it and the packaging scripts append it to artifacts at build time.
  • AI plugins used Ollama even after configuring another provider. The active provider is now inferred from the first configured provider when the selector was never changed.
  • AI provider failures are shown with full, selectable, copyable details.
  • The Export/Import menu button in repository settings now links visually with the neighbouring toolbar buttons.