refactor!: rework and expand refresh (sync) system

This commit is contained in:
Travis Abendshien
2026-09-09 23:05:02 -07:00
parent bf1018cd7e
commit 416b19e354
57 changed files with 2377 additions and 778 deletions
+73 -3
View File
@@ -9,7 +9,7 @@ from tempfile import TemporaryDirectory
import pytest
import structlog
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
DatetimeField,
TextField,
@@ -80,11 +80,52 @@ def test_library_add_file(library: Library):
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert not library.get_entry_id_from_path(entry.path)
assert library.get_entry_id_from_path(entry.path) == -1
assert library.add_entries([entry])
assert library.get_entry_id_from_path(entry.path)
def test_path_cache_untouched_when_not_yet_built(library: Library):
"""Only `get_or_build_path_cache()` may build the path cache."""
assert library.path_cache is None
entry = Entry(path=Path("before_any_cache.txt"), fields=[])
library.add_entries([entry])
assert library.path_cache is None
def test_path_cache_self_maintained_by_add_entries(library: Library):
"""`add_entries()` must keep an already-built path cache up to date on its own."""
library.is_case_sensitive_fs = True
cache = library.get_or_build_path_cache()
assert Path("added_directly.txt") not in cache
entry = Entry(path=Path("added_directly.txt"), fields=[])
new_ids = library.add_entries([entry])
assert cache.get(Path("added_directly.txt")) == new_ids[0]
def test_path_cache_self_maintained_by_remove_entries(library: Library):
library.is_case_sensitive_fs = True
cache = library.get_or_build_path_cache()
entry = Entry(path=Path("to_remove.txt"), fields=[])
entry_id = library.add_entries([entry])[0]
assert cache.get(Path("to_remove.txt")) == entry_id
library.remove_entries([entry_id])
assert Path("to_remove.txt") not in cache
def test_path_cache_self_maintained_by_update_entry_path(library: Library):
library.is_case_sensitive_fs = True
cache = library.get_or_build_path_cache()
entry = Entry(path=Path("old_location.txt"), fields=[])
entry_id = library.add_entries([entry])[0]
assert library.update_entry_path(entry_id, Path("new_location.txt"))
assert Path("old_location.txt") not in cache
assert cache.get(Path("new_location.txt")) == entry_id
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
# tag already exists
assert library.add_tag(generate_tag("foo", id=1000)) is None
@@ -148,6 +189,35 @@ def test_entries_count(library: Library):
assert len(results) == 5
@pytest.mark.parametrize(
"sorting_mode",
[SortingModeEnum.DATE_CREATED, SortingModeEnum.DATE_MODIFIED, SortingModeEnum.FILE_SIZE],
)
def test_search_library_sorting(library: Library, sorting_mode: SortingModeEnum):
entries = [
Entry(
path=Path(f"sort_{i}.txt"),
fields=[],
date_created=float(i),
date_modified=float(i),
file_size=i,
)
for i in range(3)
]
new_ids = library.add_entries(entries)
assert len(new_ids) == 3
results = library.search_library(
BrowsingState.show_all()
.with_sorting_mode(sorting_mode)
.with_sorting_direction(ascending=True),
page_size=None,
)
sorted_new_ids = [entry_id for entry_id in results if entry_id in new_ids]
assert sorted_new_ids == new_ids
def test_parents_add(library: Library, generate_tag: Callable[..., Tag]):
# Given
tag: Tag = library.tags[0]
@@ -338,7 +408,7 @@ def test_merge_entries(library: Library):
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
assert library.merge_entries(entry_a_, entry_b_)
assert not library.get_entry_id_from_path(Path("a"))
assert library.get_entry_id_from_path(Path("a")) == -1
assert library.get_entry_id_from_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))
-50
View File
@@ -1,50 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.utils.types import unwrap
CWD = Path(__file__).parent
@pytest.mark.parametrize("exclude_mode", [True, False])
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_new_files(library: Library, exclude_mode: bool):
library_dir = unwrap(library.library_dir)
# Given
registry = RefreshTracker(library=library)
library.included_files.clear()
(library_dir / "FOO.MD").touch()
(library_dir / IGNORE_NAME).write_text("*.md" if exclude_mode else "*\n!*.md")
# Test if the single file was added
list(registry.refresh_dir(library_dir, force_internal_tools=True))
assert set(registry.files_not_in_library) == set([Path(IGNORE_NAME), Path("FOO.MD")])
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_multi_byte_filenames(library: Library):
library_dir = unwrap(library.library_dir)
# Given
registry = RefreshTracker(library=library)
library.included_files.clear()
(library_dir / ".TagStudio").mkdir()
(library_dir / "こんにちは.txt").touch()
(library_dir / "emdash.txt").touch()
(library_dir / "apostrophe.txt").touch()
(library_dir / "umlaute äöü.txt").touch()
# Test if all files were added with their correct names and without exceptions
list(registry.refresh_dir(library_dir))
assert Path("こんにちは.txt") in registry.files_not_in_library
assert Path("emdash.txt") in registry.files_not_in_library
assert Path("apostrophe.txt") in registry.files_not_in_library
assert Path("umlaute äöü.txt") in registry.files_not_in_library
+406
View File
@@ -0,0 +1,406 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
import os
import unicodedata
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.sync import LibrarySyncEngine
from tagstudio.core.utils.types import unwrap
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_new_files(library: Library):
"""New files that aren't excluded by an ignore pattern must be picked up as new."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "foo.md").touch()
(library_dir / "bar.txt").touch()
ts_ignore_path = library_dir / TS_FOLDER_NAME / IGNORE_NAME
ts_ignore_path.parent.mkdir(parents=True, exist_ok=True)
ts_ignore_path.write_text("*.md")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert set(engine.new_paths) == {Path("bar.txt")}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_multi_byte_filenames(library: Library):
"""Multi-byte and accented Unicode filenames must be scanned and added without errors."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / ".TagStudio").mkdir()
(library_dir / "こんにちは.txt").touch()
(library_dir / "emdash.txt").touch()
(library_dir / "apostrophe.txt").touch()
(library_dir / "umlaute äöü.txt").touch()
list(engine.sync_dir(library_dir))
assert Path("こんにちは.txt") in engine.new_paths
assert Path("emdash.txt") in engine.new_paths
assert Path("apostrophe.txt") in engine.new_paths
assert Path("umlaute äöü.txt") in engine.new_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_unlinked_entries(library: Library):
"""An unlinked entry with one matching file must be found by `relink_unlinked_entries()`."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
# Touch the file "bar.md" but in the wrong location, to simulate a moved file
(library_dir / "bar.md").touch()
# Neither library entry ("foo.txt", "one/two/bar.md") exists on disk, so both are unlinked
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.unlinked_entries_count == 2
# Relinking bar.md should match and relink to the entry that was at "one/two/bar.md"
list(engine.relink_unlinked_entries())
assert engine.manual_relink_count == 1
assert engine.unlinked_entries_count == 1
results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500)
entries = library.get_entries(results.ids)
assert entries[0].path == Path("bar.md")
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_nfd_nfc_false_positive(library: Library):
"""A file reappearing in a different Unicode form must not look like a new/duplicate entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
nfc_name = unicodedata.normalize("NFC", "SKÅL.txt")
nfd_name = unicodedata.normalize("NFD", "SKÅL.txt")
assert nfc_name != nfd_name
(library_dir / nfc_name).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
# Simulate the file later showing up in NFD form, as if the filesystem was changed
(library_dir / nfc_name).unlink()
(library_dir / nfd_name).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert len(engine.paths_to_restat) == 1
assert engine.unlinked_entries_count == 2
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_case_sensitivity_aware_relink(library: Library):
"""`find_relink_candidates()` must respect the library's case-sensitivity setting."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "Other").mkdir()
(library_dir / "Other" / "name.txt").touch()
library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])])
list(engine.sync_dir(library_dir, force_internal_scanner=True))
unlinked_entry = next(e for e in engine.unlinked_entries if e.path == Path("Folder/Name.txt"))
library.is_case_sensitive_fs = True
assert engine.find_relink_candidates(unlinked_entry) == []
library.is_case_sensitive_fs = False
engine._filename_to_path_map = None # Rebuild the index with the new case sensitivity
assert engine.find_relink_candidates(unlinked_entry) == [Path("Other/name.txt")]
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cache_pruned_on_remove(library: Library):
"""Removing an unlinked entry must prune the path cache."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
tracked_path = Path("tracked.txt")
(library_dir / tracked_path).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / tracked_path).unlink()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
tracked_entry = next(e for e in engine.unlinked_entries if e.path == tracked_path)
cache = library.get_or_build_path_cache()
assert tracked_path in cache
# Only an explicit removal should prune the cache
engine.unlinked_entries = [tracked_entry]
engine.remove_unlinked_entries()
assert tracked_path not in cache
# A different file at the same path afterwards should be treated as new
(library_dir / tracked_path).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == [tracked_path]
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_duplicate_case_collision_treated_as_unlinked(library: Library):
"""A duplicate entry displaced by a case-insensitive collision must be treated as unlinked."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
library.is_case_sensitive_fs = False # Force a collision using a case difference
(library_dir / "Dupe").mkdir()
(library_dir / "Dupe" / "photo.jpg").touch()
entry_a_id, entry_b_id = library.add_entries(
[
Entry(path=Path("Dupe/photo.jpg"), fields=[]),
Entry(path=Path("Dupe/PHOTO.JPG"), fields=[]),
]
)
cache = library.get_or_build_path_cache()
path_key = Path("dupe/photo.jpg") # Case-insensitive + NFD
assert library.duplicate_path_entry_ids is not None
assert len(library.duplicate_path_entry_ids) == 1
dupe_id = library.duplicate_path_entry_ids[0]
original_id = entry_b_id if dupe_id == entry_a_id else entry_a_id
assert cache.get(path_key) == original_id
# The dupe ID should be marked as unlinked, and the original ID should not
list(engine.sync_dir(library_dir, force_internal_scanner=True))
unlinked_ids = {e.id for e in engine.unlinked_entries}
assert dupe_id in unlinked_ids
assert original_id not in unlinked_ids
# Removing the duplicate entry shouldn't remove the original from the cache
engine.unlinked_entries = [e for e in engine.unlinked_entries if e.id == dupe_id]
engine.remove_unlinked_entries()
assert cache.get(path_key) == original_id
assert library.duplicate_path_entry_ids == []
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_file(library: Library):
"""A moved file (same filename + stats, different path) must auto-relink to its entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "moveme.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("moveme.txt"))
assert original_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert Path("sub/moveme.txt") not in {e.path for e in engine.unlinked_entries}
assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("moveme.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_file_ambiguous(library: Library):
"""Two equally-matching candidates (same filename + stats) must not auto-relink.
NOTE: This may change in the future as capability expands.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "dupe.txt").touch()
(library_dir / "b" / "dupe.txt").touch()
st = (library_dir / "a" / "dupe.txt").stat()
os.utime(library_dir / "b" / "dupe.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "a" / "dupe.txt").unlink()
(library_dir / "b" / "dupe.txt").unlink()
(library_dir / "c").mkdir()
(library_dir / "c" / "dupe.txt").touch()
os.utime(library_dir / "c" / "dupe.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("c/dupe.txt") in engine.new_paths
unlinked_paths = {e.path for e in engine.unlinked_entries}
assert Path("a/dupe.txt") in unlinked_paths
assert Path("b/dupe.txt") in unlinked_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_file_stat_mismatch(library: Library):
"""A file with (same filename, different stats) must not auto-relink.
NOTE: This is a limitation of the current auto-relinking system, NOT a design principle.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "notmoved.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "notmoved.txt").unlink()
(library_dir / "sub").mkdir()
(library_dir / "sub" / "notmoved.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("sub/notmoved.txt") in engine.new_paths
assert Path("notmoved.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_renamed_file(library: Library):
"""A renamed file (different filename, same stats) must auto-relink to its entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("original_name.txt"))
assert original_id >= 0
(library_dir / "original_name.txt").rename(library_dir / "renamed.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert Path("renamed.txt") not in {e.path for e in engine.unlinked_entries}
assert library.get_entry_id_from_path(Path("renamed.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("original_name.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_renamed_file_ambiguous(library: Library):
"""Two equally-matching candidates (different filenames, same stats) must not auto-relink.
NOTE: This may be automated in the future, but for now differs to the user's judgement.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "one.txt").touch()
(library_dir / "b" / "two.txt").touch()
st = (library_dir / "a" / "one.txt").stat()
os.utime(library_dir / "b" / "two.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "a" / "one.txt").unlink()
(library_dir / "b" / "two.txt").unlink()
(library_dir / "c").mkdir()
(library_dir / "c" / "renamed.txt").touch()
os.utime(library_dir / "c" / "renamed.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("c/renamed.txt") in engine.new_paths
unlinked_paths = {e.path for e in engine.unlinked_entries}
assert Path("a/one.txt") in unlinked_paths
assert Path("b/two.txt") in unlinked_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_and_renamed_together(library: Library):
"""A moved file and a renamed file in the same sync must each auto-relink via their own pass."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "moveme.txt").touch()
(library_dir / "rename_orig.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
moved_id = library.get_entry_id_from_path(Path("moveme.txt"))
renamed_id = library.get_entry_id_from_path(Path("rename_orig.txt"))
assert moved_id >= 0
assert renamed_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt")
(library_dir / "rename_orig.txt").rename(library_dir / "rename_new.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 2
assert {e.id for e in engine.relinked_entries} == {moved_id, renamed_id}
assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == moved_id
assert library.get_entry_id_from_path(Path("rename_new.txt")) == renamed_id
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_unlinked_finalization(library: Library):
"""A cancelled `sync_dir()` call must not mark any entries as unlinked."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
baseline_unlinked_count = engine.unlinked_entries_count
# Case where sync is cancelled before this scan even starts
engine.cancelled = True
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.unlinked_entries_count == baseline_unlinked_count
assert engine.new_paths == []
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_save_new_entries(library: Library):
"""A cancelled `save_new_entries()` call must not save any new entries."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
baseline_count = library.entries_count
(library_dir / "f0.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == [Path("f0.txt")]
engine.cancelled = True
list(engine.save_new_entries())
assert library.entries_count == baseline_count
assert engine.new_paths == [Path("f0.txt")]
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_sync_entry_stats(library: Library):
"""A cancelled `sync_entry_stats()` call must not touch `paths_to_restat`."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "f0.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
# A second scan finds the same file again, marking it for a restat
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert len(engine.paths_to_restat) == 1
expected = list(engine.paths_to_restat)
engine.cancelled = True
list(engine.sync_entry_stats())
assert engine.paths_to_restat == expected
@@ -1,38 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.core.utils.types import unwrap
CWD = Path(__file__).parent
# NOTE: Does this test actually work?
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_unlinked_entries(library: Library):
registry = UnlinkedRegistry(lib=library)
# touch the file `one/two/bar.md` but in wrong location to simulate a moved file
(unwrap(library.library_dir) / "bar.md").touch()
# no files actually exist, so it should return all entries
assert list(registry.refresh_unlinked_files()) == [0, 1]
# neither of the library entries exist
assert len(registry.unlinked_entries) == 2
# iterate through two files
assert list(registry.fix_unlinked_entries()) == [0, 1]
# `bar.md` should be relinked to new correct path
results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500)
entries = library.get_entries(results.ids)
assert entries[0].path == Path("bar.md")
Binary file not shown.
+1 -1
View File
@@ -134,7 +134,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.ignore_modal_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.save_library_backup_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.close_library_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.refresh_dir_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.sync_library_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.tag_manager_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.color_manager_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.new_tag_action = QAction(menu_bar)