diff --git a/naturtag/app/__init__.py b/naturtag/app/__init__.py index e69de29b..4d77b373 100644 --- a/naturtag/app/__init__.py +++ b/naturtag/app/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa: F401 +from naturtag.app.app import NaturtagApp diff --git a/naturtag/app/app.py b/naturtag/app/app.py index 99623684..f3cf986d 100644 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -39,29 +39,41 @@ logger = getLogger(__name__) -# TODO: Global access to Settings object instead of passing it around everywhere? -class MainWindow(QMainWindow): - def __init__(self, settings: Settings): - super().__init__() - self.setWindowTitle('Naturtag') - self.resize(*settings.window_size) - self.threadpool = ThreadPool() - log_handler = init_handler( - settings.log_level, root_level=settings.log_level_external, logfile=settings.logfile - ) +class NaturtagApp(QApplication): + def __init__(self, *args, settings: Settings, **kwargs): + super().__init__(*args, **kwargs) + self.setApplicationName('Naturtag') + self.setOrganizationName('pyinat') + self.setApplicationVersion(pkg_version('naturtag')) # Run any first-time setup steps, if needed setup(settings) + + # Globally available application objects self.settings = settings - self.user_dirs = UserDirs(settings) self.client = iNatDbClient(settings.db_path) self.img_session = ImageSession(settings.image_cache_path) + self.log_handler = init_handler( + self.settings.log_level, + root_level=self.settings.log_level_external, + logfile=self.settings.logfile, + ) + self.threadpool = ThreadPool() + self.user_dirs = UserDirs(self.settings) + + +class MainWindow(QMainWindow): + def __init__(self, app: NaturtagApp): + super().__init__() + self.setWindowTitle('Naturtag') + self.resize(*app.settings.window_size) + self.app = app # Controllers - self.settings_menu = SettingsMenu(self.settings) - self.image_controller = ImageController(self.settings, self.threadpool) - self.taxon_controller = TaxonController(self.settings, self.threadpool) - self.observation_controller = ObservationController(self.settings, self.threadpool) + self.settings_menu = SettingsMenu() + self.image_controller = ImageController() + self.taxon_controller = TaxonController() + self.observation_controller = ObservationController() # Connect controllers and their widgets to statusbar info self.settings_menu.on_message.connect(self.info) @@ -108,12 +120,14 @@ def __init__(self, settings: Settings): self.root_widget = QWidget() self.root = VerticalLayout(self.root_widget) self.root.addWidget(self.tabs) - self.root.addWidget(self.threadpool.progress) + self.root.addWidget(self.app.threadpool.progress) self.setCentralWidget(self.root_widget) # Optionally show Logs tab - self.log_tab_idx = self.tabs.addTab(log_handler.widget, fa_icon('fa.file-text-o'), 'Logs') - self.tabs.setTabVisible(self.log_tab_idx, self.settings.show_logs) + self.log_tab_idx = self.tabs.addTab( + self.app.log_handler.widget, fa_icon('fa.file-text-o'), 'Logs' + ) + self.tabs.setTabVisible(self.log_tab_idx, self.app.settings.show_logs) # Switch to differet tab if requested from Photos tab self.image_controller.on_select_taxon_tab.connect( @@ -124,11 +138,11 @@ def __init__(self, settings: Settings): ) # Connect file picker <--> recent/favorite dirs - self.image_controller.gallery.on_load_images.connect(self.user_dirs.add_recent_dirs) - self.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog) + self.image_controller.gallery.on_load_images.connect(self.app.user_dirs.add_recent_dirs) + self.app.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog) # Toolbar actions - self.toolbar = Toolbar(self, self.user_dirs) + self.toolbar = Toolbar(self, self.app.user_dirs) self.toolbar.run_button.triggered.connect(self.image_controller.run) self.toolbar.refresh_tags_button.triggered.connect(self.image_controller.refresh) self.toolbar.open_button.triggered.connect(self.image_controller.gallery.load_file_dialog) @@ -154,7 +168,7 @@ def __init__(self, settings: Settings): ) # Debug - if settings.debug: + if self.app.settings.debug: QShortcut(QKeySequence('F9'), self).activated.connect(self.reload_qss) demo_images = list((ASSETS_DIR / 'demo_images').glob('*.jpg')) self.image_controller.gallery.load_images(demo_images) # type: ignore @@ -162,7 +176,7 @@ def __init__(self, settings: Settings): def closeEvent(self, _): """Save settings before closing the app""" - self.settings.write() + self.app.settings.write() self.taxon_controller.user_taxa.write() def info(self, message: str): @@ -191,7 +205,7 @@ def open_about(self): repo_link = f"{REPO_URL}" license_link = f"MIT License" attribution = f'Ⓒ {datetime.now().year} Jordan Cook, {license_link}' - app_dir_link = f"{self.settings.data_dir}" + app_dir_link = f"{self.app.settings.data_dir}" about.setText( f'Naturtag v{version}
' @@ -203,7 +217,7 @@ def open_about(self): def reload_qss(self): """Reload Qt stylesheet""" - set_theme(dark_mode=self.settings.dark_mode) + set_theme(dark_mode=self.app.settings.dark_mode) # TODO: progress spinner def reset_db(self): @@ -215,7 +229,7 @@ def reset_db(self): ) if response == QMessageBox.Yes: self.info('Resetting database...') - setup(self.settings, overwrite=True) + setup(self.app.settings, overwrite=True) self.info('Database reset complete') def show_settings(self): @@ -240,14 +254,14 @@ def toggle_log_tab(self, checked: bool = True): def main(): - app = QApplication(sys.argv) + settings = Settings.read() + app = NaturtagApp(sys.argv, settings=settings) splash = QSplashScreen(QPixmap(str(APP_LOGO)).scaledToHeight(512)) splash.show() app.setWindowIcon(QIcon(QPixmap(str(APP_ICON)))) - settings = Settings.read() set_theme(dark_mode=settings.dark_mode) - window = MainWindow(settings) + window = MainWindow(app) window.show() splash.finish(window) sys.exit(app.exec()) diff --git a/naturtag/app/settings_menu.py b/naturtag/app/settings_menu.py index fcc61966..bbdb0bc1 100644 --- a/naturtag/app/settings_menu.py +++ b/naturtag/app/settings_menu.py @@ -25,78 +25,116 @@ class SettingsMenu(BaseController): """Application settings menu, with input widgets connected to values in settings file""" - def __init__(self, settings: Settings): - super().__init__(settings) - self.settings = settings + def __init__(self): + super().__init__() self.settings_layout = VerticalLayout(self) # Dictionary of locale codes and display names self.locales = {k: f'{v} ({k})' for k, v in read_locales().items()} # iNaturalist settings inat = self.add_group('iNaturalist', self.settings_layout) - inat.addLayout(TextSetting(settings, icon_str='fa.user', setting_attr='username')) + inat.addLayout( + TextSetting( + self.app.settings, + icon_str='fa.user', + setting_attr='username', + ) + ) inat.addLayout( ChoiceAltDisplaySetting( - settings, + self.app.settings, icon_str='fa.globe', setting_attr='locale', choices=self.locales, ) ) inat.addLayout( - ToggleSetting(settings, icon_str='fa.language', setting_attr='search_locale') + ToggleSetting( + self.app.settings, + icon_str='fa.language', + setting_attr='search_locale', + ) ) inat.addLayout( IntSetting( - settings, icon_str='mdi.home-city-outline', setting_attr='preferred_place_id' + self.app.settings, + icon_str='mdi.home-city-outline', + setting_attr='preferred_place_id', ) ) inat.addLayout( - ToggleSetting(settings, icon_str='mdi6.cat', setting_attr='casual_observations') + ToggleSetting( + self.app.settings, + icon_str='mdi6.cat', + setting_attr='casual_observations', + ) ) self.all_ranks = ToggleSetting( - settings, icon_str='fa.chevron-circle-up', setting_attr='all_ranks' + self.app.settings, + icon_str='fa.chevron-circle-up', + setting_attr='all_ranks', ) inat.addLayout(self.all_ranks) # Metadata settings metadata = self.add_group('Metadata', self.settings_layout) metadata.addLayout( - ToggleSetting(settings, icon_str='fa.language', setting_attr='common_names') + ToggleSetting( + self.app.settings, + icon_str='fa.language', + setting_attr='common_names', + ) ) metadata.addLayout( - ToggleSetting(settings, icon_str='mdi.file-tree', setting_attr='hierarchical') + ToggleSetting( + self.app.settings, + icon_str='mdi.file-tree', + setting_attr='hierarchical', + ) ) metadata.addLayout( - ToggleSetting(settings, icon_str='fa5s.file-code', setting_attr='sidecar') + ToggleSetting( + self.app.settings, + icon_str='fa5s.file-code', + setting_attr='sidecar', + ) ) metadata.addLayout( ToggleSetting( - settings, icon_str='fa5s.file-alt', setting_attr='exif', setting_title='EXIF' + self.app.settings, + icon_str='fa5s.file-alt', + setting_attr='exif', + setting_title='EXIF', ) ) metadata.addLayout( ToggleSetting( - settings, icon_str='fa5s.file-alt', setting_attr='iptc', setting_title='IPTC' + self.app.settings, + icon_str='fa5s.file-alt', + setting_attr='iptc', + setting_title='IPTC', ) ) metadata.addLayout( ToggleSetting( - settings, icon_str='fa5s.file-alt', setting_attr='xmp', setting_title='XMP' + self.app.settings, + icon_str='fa5s.file-alt', + setting_attr='xmp', + setting_title='XMP', ) ) # User data settings user_data = self.add_group('User Data', self.settings_layout) use_last_dir = ToggleSetting( - settings, + self.app.settings, icon_str='mdi.folder-clock-outline', setting_attr='use_last_dir', setting_title='Use last directory', ) user_data.addLayout(use_last_dir) self.default_image_dir = PathSetting( - settings, + self.app.settings, icon_str='fa5.images', setting_attr='default_image_dir', setting_title='Default image directory', @@ -105,7 +143,7 @@ def __init__(self, settings: Settings): user_data.addLayout(self.default_image_dir) # Disable default_image_dir option when use_last_dir is enabled - self.default_image_dir.setEnabled(not settings.use_last_dir) + self.default_image_dir.setEnabled(not self.app.settings.use_last_dir) use_last_dir.on_click.connect( lambda checked: self.default_image_dir.setEnabled(not checked) ) @@ -113,7 +151,7 @@ def __init__(self, settings: Settings): # Display settings display = self.add_group('Display', self.settings_layout) self.dark_mode = ToggleSetting( - settings, + self.app.settings, icon_str='mdi.theme-light-dark', setting_attr='dark_mode', ) @@ -122,13 +160,13 @@ def __init__(self, settings: Settings): # Debug settings debug = self.add_group('Debug', self.settings_layout) self.show_logs = ToggleSetting( - settings, + self.app.settings, icon_str='fa.file-text-o', setting_attr='show_logs', ) debug.addLayout(self.show_logs) self.log_level = ChoiceSetting( - settings, + self.app.settings, icon_str='fa.thermometer-2', setting_attr='log_level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], @@ -145,7 +183,7 @@ def add_group(self, *args, **kwargs): def closeEvent(self, event): """Save settings when closing the window""" - self.settings.write() + self.app.settings.write() self.on_message.emit('Settings saved') event.accept() diff --git a/naturtag/cli.py b/naturtag/cli.py index dccc4d25..17b17d1c 100644 --- a/naturtag/cli.py +++ b/naturtag/cli.py @@ -21,9 +21,9 @@ from rich.table import Column, Table from naturtag.constants import CLI_COMPLETE_DIR -from naturtag.metadata import KeywordMetadata, MetaMetadata, refresh_tags, strip_url, tag_images +from naturtag.metadata import KeywordMetadata, MetaMetadata, refresh_tags, tag_images from naturtag.settings import Settings, setup -from naturtag.utils.image_glob import get_valid_image_paths +from naturtag.utils import get_valid_image_paths, strip_url CODE_BLOCK = re.compile(r'```\n\s*(.+?)```\n', re.DOTALL) CODE_INLINE = re.compile(r'`([^`]+?)`') diff --git a/naturtag/client.py b/naturtag/client.py index 9136bfbe..1f82d0dd 100644 --- a/naturtag/client.py +++ b/naturtag/client.py @@ -30,6 +30,34 @@ def __init__(self, db_path: Path = DB_PATH, **kwargs): self.taxa = TaxonDbController(self) self.observations = ObservationDbController(self, taxon_controller=self.taxa) + def from_ids( + self, observation_id: Optional[int] = None, taxon_id: Optional[int] = None + ) -> Optional[Observation]: + """Get an iNaturalist observation and/or taxon matching the specified ID(s). If only a taxon ID + is provided, the observation will be a placeholder with only the taxon field populated. + """ + # Get observation record, if available + if observation_id: + observation = self.observations(observation_id, refresh=True) + taxon_id = observation.taxon.id + # Otherwise, use an empty placeholder observation + else: + observation = Observation() + + # Observation.taxon doesn't include ancestors, so we always need to fetch the full taxon record + observation.taxon = self.taxa(taxon_id) + if not observation.taxon: + logger.warning(f'No taxon found: {taxon_id}') + return None + + # If there's a taxon only (no observation), check for any taxonomy changes + # TODO: Add this to pyinat: https://github.com/pyinat/pyinaturalist/issues/444 + synonyms = observation.taxon.current_synonymous_taxon_ids + if not observation_id and not observation.taxon.is_active and len(synonyms or []) == 1: + observation.taxon = self.taxa(synonyms[0], refresh=True) + + return observation + # TODO: Expiration? class ObservationDbController(ObservationController): @@ -240,8 +268,3 @@ def get_url_hash(url: str) -> str: thumbnail_hash = md5(url.encode()).hexdigest() ext = Photo(url=url).ext return f'{thumbnail_hash}.{ext}' - - -# TODO: Refactor to depend on app.client and session objects instead of these module-level globals -INAT_CLIENT = iNatDbClient() -IMG_SESSION = ImageSession() diff --git a/naturtag/controllers/__init__.py b/naturtag/controllers/__init__.py index ee1c17c4..b2d04f5c 100644 --- a/naturtag/controllers/__init__.py +++ b/naturtag/controllers/__init__.py @@ -1,5 +1,16 @@ # flake8: noqa: F401 # isort: skip_file +from typing import TYPE_CHECKING +from PySide6.QtWidgets import QApplication + + +if TYPE_CHECKING: + from naturtag.app import NaturtagApp + + +def get_app() -> 'NaturtagApp': + return QApplication.instance() + from naturtag.controllers.base_controller import BaseController from naturtag.controllers.image_gallery import ImageGallery diff --git a/naturtag/controllers/base_controller.py b/naturtag/controllers/base_controller.py index 00cb6d39..7090707f 100644 --- a/naturtag/controllers/base_controller.py +++ b/naturtag/controllers/base_controller.py @@ -1,18 +1,19 @@ -from typing import Optional +from typing import TYPE_CHECKING from PySide6.QtCore import Signal +from PySide6.QtWidgets import QApplication -from naturtag.app.threadpool import ThreadPool -from naturtag.settings import Settings from naturtag.widgets.layouts import StylableWidget +if TYPE_CHECKING: + from naturtag.app import NaturtagApp + class BaseController(StylableWidget): """Base class for controllers, typically in charge of a single tab/screen""" on_message = Signal(str) #: Forward a message to status bar - def __init__(self, settings: Settings, threadpool: Optional[ThreadPool] = None): - super().__init__() - self.settings = settings - self.threadpool: ThreadPool = threadpool # type: ignore + @property + def app(self) -> 'NaturtagApp': + return QApplication.instance() diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py index 5a3e9196..474807a8 100644 --- a/naturtag/controllers/image_controller.py +++ b/naturtag/controllers/image_controller.py @@ -6,7 +6,8 @@ from PySide6.QtWidgets import QApplication, QGroupBox, QLabel, QSizePolicy from naturtag.controllers import BaseController, ImageGallery -from naturtag.metadata import MetaMetadata, _refresh_tags, get_ids_from_url, tag_images +from naturtag.metadata import MetaMetadata, _refresh_tags, tag_images +from naturtag.utils import get_ids_from_url from naturtag.widgets import ( HorizontalLayout, IdInput, @@ -28,8 +29,8 @@ class ImageController(BaseController): on_select_observation_id = Signal(int) #: An observation ID was entered on_select_observation_tab = Signal() #: Request to switch to observation tab - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self): + super().__init__() photo_layout = VerticalLayout(self) top_section_layout = HorizontalLayout() top_section_layout.setAlignment(Qt.AlignLeft) @@ -68,7 +69,7 @@ def __init__(self, *args, **kwargs): self.input_taxon_id.on_clear.connect(self.data_source_card.clear) # Image gallery - self.gallery = ImageGallery(self.settings, self.threadpool) + self.gallery = ImageGallery() self.gallery.on_select_observation.connect(self.on_select_observation_tab) self.gallery.on_select_taxon.connect(self.on_select_taxon_tab) photo_layout.addWidget(self.gallery) @@ -89,10 +90,16 @@ def run(self): logger.info(f'Tagging {len(image_paths)} images with metadata for {selected_id}') def tag_image(image_path): - return tag_images([image_path], obs_id, taxon_id, settings=self.settings)[0] + return tag_images( + [image_path], + obs_id, + taxon_id, + client=self.app.client, + settings=self.app.settings, + )[0] for image_path in image_paths: - future = self.threadpool.schedule(tag_image, image_path=image_path) + future = self.app.threadpool.schedule(tag_image, image_path=image_path) future.on_result.connect(self.update_metadata) self.info(f'{len(image_paths)} images tagged with metadata for {selected_id}') @@ -110,8 +117,8 @@ def refresh(self): return for image in images: - future = self.threadpool.schedule( - lambda: _refresh_tags(image.metadata, self.settings), + future = self.app.threadpool.schedule( + lambda: _refresh_tags(image.metadata, self.app.client, self.app.settings), ) future.on_result.connect(self.update_metadata) self.info(f'{len(images)} images updated') diff --git a/naturtag/controllers/image_gallery.py b/naturtag/controllers/image_gallery.py index 48e779a3..936649d8 100644 --- a/naturtag/controllers/image_gallery.py +++ b/naturtag/controllers/image_gallery.py @@ -52,8 +52,8 @@ class ImageGallery(BaseController): on_select_taxon = Signal(int) #: A taxon was selected from context menu on_select_observation = Signal(int) #: An observation was selected from context menu - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self): + super().__init__() self.setAcceptDrops(True) self.images: dict[Path, ThumbnailCard] = {} self.image_window = ImageWindow() @@ -82,7 +82,7 @@ def load_file_dialog(self, start_dir: Optional[PathOrStr] = None): image_paths, _ = QFileDialog.getOpenFileNames( self, caption='Open image files:', - dir=str(start_dir or self.settings.start_image_dir), + dir=str(start_dir or self.app.settings.start_image_dir), filter=f'Image files ({" ".join(IMAGE_FILETYPES)})', ) self.load_images(image_paths) @@ -100,7 +100,7 @@ def load_images(self, image_paths: Iterable[PathOrStr]): # Then load actual images for thumbnail_card in filter(None, cards): - thumbnail_card.load_image_async(self.threadpool) + thumbnail_card.load_image_async(self.app.threadpool) self.on_load_images.emit(new_images) diff --git a/naturtag/controllers/observation_controller.py b/naturtag/controllers/observation_controller.py index b3aa1f24..058eee3e 100644 --- a/naturtag/controllers/observation_controller.py +++ b/naturtag/controllers/observation_controller.py @@ -6,7 +6,6 @@ from PySide6.QtWidgets import QLabel, QPushButton from naturtag.app.style import fa_icon -from naturtag.client import INAT_CLIENT from naturtag.constants import DEFAULT_PAGE_SIZE from naturtag.controllers import BaseController, ObservationInfoSection from naturtag.widgets import HorizontalLayout, ObservationInfoCard, ObservationList, VerticalLayout @@ -17,14 +16,14 @@ class ObservationController(BaseController): on_select = Signal(Observation) #: An observation was selected - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self): + super().__init__() self.root = HorizontalLayout(self) self.root.setAlignment(Qt.AlignLeft) self.selected_observation: Observation = None # Search inputs - # self.search = ObservationSearch(self.settings) + # self.search = ObservationSearch(self.app.settings) # self.search.autocomplete.on_select.connect(self.select_taxon) # self.search.on_results.connect(self.set_search_results) # self.on_select.connect(self.search.set_taxon) @@ -37,7 +36,7 @@ def __init__(self, *args, **kwargs): # self.pages: dict[int, list[ObservationInfoCard]] = {} # User observations - self.user_observations = ObservationList(self.threadpool) + self.user_observations = ObservationList() user_obs_group_box = self.add_group( 'My Observations', self.root, @@ -67,7 +66,7 @@ def __init__(self, *args, **kwargs): button_layout.addWidget(self.next_button) # Selected observation info - self.obs_info = ObservationInfoSection(self.threadpool) + self.obs_info = ObservationInfoSection() self.obs_info.on_select.connect(self.display_observation) obs_layout = VerticalLayout() obs_layout.addLayout(self.obs_info) @@ -90,8 +89,8 @@ def select_observation(self, observation_id: int): return logger.info(f'Selecting observation {observation_id}') - future = self.threadpool.schedule( - lambda: INAT_CLIENT.observations(observation_id, taxonomy=True), + future = self.app.threadpool.schedule( + lambda: self.app.client.observations(observation_id, taxonomy=True), priority=QThread.HighPriority, ) future.on_result.connect(self.display_observation) @@ -99,7 +98,9 @@ def select_observation(self, observation_id: int): def load_user_observations(self): """Fetch and display a single page of user observations""" logger.info('Fetching user observations') - future = self.threadpool.schedule(self.get_user_observations, priority=QThread.LowPriority) + future = self.app.threadpool.schedule( + self.get_user_observations, priority=QThread.LowPriority + ) future.on_result.connect(self.display_user_observations) def next_page(self): @@ -151,20 +152,20 @@ def bind_selection(self, obs_cards: Iterable[ObservationInfoCard]): # TODO: Store a Paginator object instead of page number? def get_user_observations(self) -> list[Observation]: """Fetch a single page of user observations""" - if not self.settings.username: + if not self.app.settings.username: return [] - updated_since = self.settings.last_obs_check - self.settings.set_obs_checkpoint() + updated_since = self.app.settings.last_obs_check + self.app.settings.set_obs_checkpoint() # TODO: Depending on order of operations, this could be counted from the db instead of API. # Maybe do that except on initial observation load? - total_results = INAT_CLIENT.observations.count(username=self.settings.username) + total_results = self.app.client.observations.count(username=self.app.settings.username) self.total_pages = (total_results // DEFAULT_PAGE_SIZE) + 1 logger.debug('Total user observations: %s (%s pages)', total_results, self.total_pages) - observations = INAT_CLIENT.observations.get_user_observations( - username=self.settings.username, + observations = self.app.client.observations.get_user_observations( + username=self.app.settings.username, updated_since=updated_since, limit=DEFAULT_PAGE_SIZE, page=self.page, diff --git a/naturtag/controllers/observation_search.py b/naturtag/controllers/observation_search.py index 077fc81a..90a92c60 100644 --- a/naturtag/controllers/observation_search.py +++ b/naturtag/controllers/observation_search.py @@ -4,15 +4,13 @@ from pyinaturalist import Observation from PySide6.QtCore import Qt -from naturtag.settings import Settings from naturtag.widgets import VerticalLayout logger = getLogger(__name__) class ObservationSearch(VerticalLayout): - def __init__(self, settings: Settings): + def __init__(self): super().__init__() self.selected_observation: Observation = None - self.settings = settings self.setAlignment(Qt.AlignTop) diff --git a/naturtag/controllers/observation_view.py b/naturtag/controllers/observation_view.py index 31d24bf4..6bc99f01 100644 --- a/naturtag/controllers/observation_view.py +++ b/naturtag/controllers/observation_view.py @@ -10,7 +10,6 @@ from PySide6.QtWidgets import QGroupBox, QLabel, QPushButton from naturtag.app.style import fa_icon -from naturtag.app.threadpool import ThreadPool from naturtag.constants import SIZE_SM from naturtag.widgets import ( GridLayout, @@ -19,6 +18,7 @@ ObservationImageWindow, ObservationPhoto, VerticalLayout, + set_pixmap_async, ) from naturtag.widgets.observation_images import GEOPRIVACY_ICONS, QUALITY_GRADE_ICONS @@ -30,9 +30,8 @@ class ObservationInfoSection(HorizontalLayout): on_select = Signal(Observation) #: An observation was selected - def __init__(self, threadpool: ThreadPool): + def __init__(self): super().__init__() - self.threadpool = threadpool self.hist_prev: deque[Observation] = deque() # Viewing history for current session only self.hist_next: deque[Observation] = deque() # Set when loading from history self.history_observation: Observation = None # Set when loading from history, to avoid loop @@ -121,9 +120,9 @@ def load(self, obs: Observation): self.group_box.setTitle(obs.taxon.full_name) self.image.hover_event = True self.image.observation = obs - self.image.set_pixmap_async( - self.threadpool, - photo=obs.photos[0], # TODO: add Observation.default_photo in pyinat + set_pixmap_async( + self.image, + photo=Observation.default_photo, priority=QThread.HighPriority, ) self._update_nav_buttons() @@ -134,7 +133,7 @@ def load(self, obs: Observation): thumb = ObservationPhoto(observation=obs, idx=i + 1, rounded=True) thumb.setFixedSize(*SIZE_SM) thumb.on_click.connect(self.image_window.display_observation_fullscreen) - thumb.set_pixmap_async(self.threadpool, photo=photo, size='thumbnail') + set_pixmap_async(thumb, photo=photo, size='thumbnail') self.thumbnails.addWidget(thumb) # Load observation details diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py index 467d19fc..9fb5ae66 100644 --- a/naturtag/controllers/taxon_controller.py +++ b/naturtag/controllers/taxon_controller.py @@ -6,11 +6,15 @@ from PySide6.QtWidgets import QTabWidget, QWidget from naturtag.app.style import fa_icon -from naturtag.app.threadpool import ThreadPool -from naturtag.client import INAT_CLIENT from naturtag.constants import MAX_DISPLAY_OBSERVED -from naturtag.controllers import BaseController, TaxonInfoSection, TaxonomySection, TaxonSearch -from naturtag.settings import Settings, UserTaxa +from naturtag.controllers import ( + BaseController, + TaxonInfoSection, + TaxonomySection, + TaxonSearch, + get_app, +) +from naturtag.settings import UserTaxa from naturtag.widgets import HorizontalLayout, TaxonInfoCard, TaxonList, VerticalLayout logger = getLogger(__name__) @@ -21,33 +25,33 @@ class TaxonController(BaseController): on_select = Signal(Taxon) #: A taxon was selected - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user_taxa = UserTaxa.read(self.settings.user_taxa_path) + def __init__(self): + super().__init__() + self.user_taxa = UserTaxa.read(self.app.settings.user_taxa_path) self.root = HorizontalLayout(self) self.root.setAlignment(Qt.AlignLeft) self.selected_taxon: Taxon = None # Search inputs - self.search = TaxonSearch(self.settings) + self.search = TaxonSearch() self.search.autocomplete.on_select.connect(self.select_taxon) self.search.on_results.connect(self.set_search_results) self.on_select.connect(self.search.set_taxon) self.root.addLayout(self.search) # Search results & user taxa - self.tabs = TaxonTabs(self.settings, self.threadpool, self.user_taxa) + self.tabs = TaxonTabs(self.user_taxa) self.tabs.on_load.connect(self.bind_selection) self.root.addWidget(self.tabs) self.on_select.connect(self.tabs.update_history) self.search.on_reset.connect(self.tabs.results.clear) # Selected taxon info - self.taxon_info = TaxonInfoSection(self.threadpool) + self.taxon_info = TaxonInfoSection() self.taxon_info.on_select_id.connect(self.select_taxon) self.taxon_info.on_select.connect(self.display_taxon) - self.taxonomy = TaxonomySection(self.threadpool, self.user_taxa) + self.taxonomy = TaxonomySection(self.user_taxa) taxon_layout = VerticalLayout() taxon_layout.addLayout(self.taxon_info) taxon_layout.addLayout(self.taxonomy) @@ -71,10 +75,11 @@ def select_taxon(self, taxon_id: int): # Fetch taxon record logger.info(f'Selecting taxon {taxon_id}') + client = self.app.client if self.tabs._init_complete: - self.threadpool.cancel() - future = self.threadpool.schedule( - lambda: INAT_CLIENT.taxa(taxon_id, locale=self.settings.locale), + self.app.threadpool.cancel() + future = self.app.threadpool.schedule( + lambda: client.taxa(taxon_id, locale=self.app.settings.locale), priority=QThread.HighPriority, ) future.on_result.connect(lambda taxon: self.display_taxon(taxon)) @@ -111,8 +116,6 @@ class TaxonTabs(QTabWidget): def __init__( self, - settings: Settings, - threadpool: ThreadPool, user_taxa: UserTaxa, parent: Optional[QWidget] = None, ): @@ -121,25 +124,32 @@ def __init__( self.setIconSize(QSize(32, 32)) self.setMinimumWidth(240) self.setMaximumWidth(510) - self.settings = settings - self.threadpool = threadpool self.user_taxa = user_taxa self._init_complete = False self.results = self.add_tab( - TaxonList(threadpool, user_taxa), 'mdi6.layers-search', 'Results', 'Search results' + TaxonList(user_taxa), + 'mdi6.layers-search', + 'Results', + 'Search results', ) self.recent = self.add_tab( - TaxonList(threadpool, user_taxa), 'fa5s.history', 'Recent', 'Recently viewed taxa' + TaxonList(user_taxa), + 'fa5s.history', + 'Recent', + 'Recently viewed taxa', ) self.frequent = self.add_tab( - TaxonList(threadpool, user_taxa), + TaxonList(user_taxa), 'ri.bar-chart-fill', 'Frequent', 'Frequently viewed taxa', ) self.observed = self.add_tab( - TaxonList(threadpool, user_taxa), 'fa5s.binoculars', 'Observed', 'Taxa observed by you' + TaxonList(user_taxa), + 'fa5s.binoculars', + 'Observed', + 'Taxa observed by you', ) # Add a delay before loading user taxa on startup @@ -152,31 +162,34 @@ def add_tab(self, taxon_list: TaxonList, icon_str: str, label: str, tooltip: str def load_user_taxa(self): logger.info('Fetching user-observed taxa') + app = get_app() + client = app.client display_ids = self.user_taxa.display_ids def get_recent_taxa(): logger.info(f'Loading {len(display_ids)} user taxa') - return INAT_CLIENT.taxa.from_ids( - *display_ids, locale=self.settings.locale, accept_partial=True + return client.taxa.from_ids( + *display_ids, locale=app.settings.locale, accept_partial=True ).all() - future = self.threadpool.schedule(get_recent_taxa, priority=QThread.LowPriority) + future = app.threadpool.schedule(get_recent_taxa, priority=QThread.LowPriority) future.on_result.connect(self.display_recent) - future = self.threadpool.schedule(self.get_user_observed_taxa, priority=QThread.LowPriority) + future = app.threadpool.schedule(self.get_user_observed_taxa, priority=QThread.LowPriority) future.on_result.connect(self.display_observed) self._init_complete = True def get_user_observed_taxa(self) -> TaxonCounts: """Get counts of taxa observed by the user, ordered by number of observations descending""" - if not self.settings.username: + app = get_app() + if not app.settings.username: return [] # False will return *only* casual observations - verifiable = None if self.settings.casual_observations else True + verifiable = None if app.settings.casual_observations else True - taxon_counts = INAT_CLIENT.observations.species_counts( - user_login=self.settings.username, + taxon_counts = app.client.observations.species_counts( + user_login=app.settings.username, verifiable=verifiable, ) logger.info(f'{len(taxon_counts)} user-observed taxa found') diff --git a/naturtag/controllers/taxon_search.py b/naturtag/controllers/taxon_search.py index cfe1dd33..b1c4bf84 100644 --- a/naturtag/controllers/taxon_search.py +++ b/naturtag/controllers/taxon_search.py @@ -8,9 +8,8 @@ from PySide6.QtWidgets import QApplication, QComboBox, QLabel, QPushButton, QWidget from naturtag.app.style import fa_icon -from naturtag.client import INAT_CLIENT from naturtag.constants import COMMON_RANKS, RANKS, SELECTABLE_ICONIC_TAXA -from naturtag.settings import Settings +from naturtag.controllers import get_app from naturtag.widgets import ( GridLayout, HorizontalLayout, @@ -28,14 +27,13 @@ class TaxonSearch(VerticalLayout): on_results = Signal(list) #: New search results were loaded on_reset = Signal() #: Input fields were reset - def __init__(self, settings: Settings): + def __init__(self): super().__init__() self.selected_taxon: Taxon = None - self.settings = settings self.setAlignment(Qt.AlignTop) # Taxon name autocomplete - self.autocomplete = TaxonAutocomplete(settings) + self.autocomplete = TaxonAutocomplete() search_group = self.add_group('Search', self, width=400) search_group.addWidget(self.autocomplete) self.autocomplete.returnPressed.connect(self.search) @@ -86,18 +84,20 @@ def __init__(self, settings: Settings): def search(self): """Search for taxa with the currently selected filters""" + client = QApplication.instance().client taxon_ids = self.iconic_taxon_filters.selected_iconic_taxa if self.search_children_switch.isChecked(): taxon_ids.append(self.selected_taxon.id) - taxa = INAT_CLIENT.taxa.search( + settings = get_app().settings + taxa = client.taxa.search( q=self.autocomplete.text(), taxon_id=taxon_ids, rank=self.exact_rank.text, min_rank=self.min_rank.text, max_rank=self.max_rank.text, - preferred_place_id=self.settings.preferred_place_id, - locale=self.settings.locale, + preferred_place_id=settings.preferred_place_id, + locale=settings.locale, limit=30, ).all() @@ -113,13 +113,10 @@ def reset(self): self.on_reset.emit() def reset_ranks(self): - self.exact_rank = RankList('Exact', 'fa5s.equals', all_ranks=self.settings.all_ranks) - self.min_rank = RankList( - 'Minimum', 'fa5s.greater-than-equal', all_ranks=self.settings.all_ranks - ) - self.max_rank = RankList( - 'Maximum', 'fa5s.less-than-equal', all_ranks=self.settings.all_ranks - ) + settings = get_app().settings + self.exact_rank = RankList('Exact', 'fa5s.equals', all_ranks=settings.all_ranks) + self.min_rank = RankList('Minimum', 'fa5s.greater-than-equal', all_ranks=settings.all_ranks) + self.max_rank = RankList('Maximum', 'fa5s.less-than-equal', all_ranks=settings.all_ranks) self.ranks.clear() self.ranks.addLayout(self.exact_rank) self.ranks.addLayout(self.min_rank) diff --git a/naturtag/controllers/taxon_view.py b/naturtag/controllers/taxon_view.py index fad6b421..668ef40d 100644 --- a/naturtag/controllers/taxon_view.py +++ b/naturtag/controllers/taxon_view.py @@ -9,7 +9,6 @@ from PySide6.QtWidgets import QGroupBox, QPushButton from naturtag.app.style import fa_icon -from naturtag.app.threadpool import ThreadPool from naturtag.constants import SIZE_SM from naturtag.settings import UserTaxa from naturtag.widgets import ( @@ -18,6 +17,7 @@ TaxonImageWindow, TaxonInfoCard, TaxonList, + set_pixmap_async, ) from naturtag.widgets.layouts import VerticalLayout from naturtag.widgets.taxon_images import TaxonPhoto @@ -31,9 +31,8 @@ class TaxonInfoSection(HorizontalLayout): on_select = Signal(Taxon) #: A taxon object was selected (from nav or another screen) on_select_id = Signal(int) #: A taxon ID was selected (from 'parent' button) - def __init__(self, threadpool: ThreadPool): + def __init__(self): super().__init__() - self.threadpool = threadpool self.hist_prev: deque[Taxon] = deque() # Viewing history for current session only self.hist_next: deque[Taxon] = deque() # Set when loading from history self.history_taxon: Taxon = None # Set when loading from history, to avoid loop @@ -111,11 +110,7 @@ def load(self, taxon: Taxon): self.group_box.setTitle(taxon.full_name) self.image.hover_event = True self.image.taxon = taxon - self.image.set_pixmap_async( - self.threadpool, - photo=taxon.default_photo, - priority=QThread.HighPriority, - ) + set_pixmap_async(self.image, photo=taxon.default_photo, priority=QThread.HighPriority) self._update_nav_buttons() # Load additional thumbnails @@ -124,7 +119,7 @@ def load(self, taxon: Taxon): thumb = TaxonPhoto(taxon=taxon, idx=i + 1, rounded=True) thumb.setFixedSize(*SIZE_SM) thumb.on_click.connect(self.image_window.display_taxon_fullscreen) - thumb.set_pixmap_async(self.threadpool, photo=photo, size='thumbnail') + set_pixmap_async(thumb, photo=photo, size='thumbnail') self.thumbnails.addWidget(thumb) def prev(self): @@ -165,19 +160,19 @@ def _update_nav_buttons(self): class TaxonomySection(HorizontalLayout): """Section to display ancestors and children of selected taxon""" - def __init__(self, threadpool: ThreadPool, user_taxa: UserTaxa): + def __init__(self, user_taxa: UserTaxa): super().__init__() self.ancestors_group = self.add_group( 'Ancestors', min_width=400, max_width=500, policy_min_height=False ) - self.ancestors_list = TaxonList(threadpool, user_taxa) + self.ancestors_list = TaxonList(user_taxa) self.ancestors_group.addWidget(self.ancestors_list.scroller) self.children_group = self.add_group( 'Children', min_width=400, max_width=500, policy_min_height=False ) - self.children_list = TaxonList(threadpool, user_taxa) + self.children_list = TaxonList(user_taxa) self.children_group.addWidget(self.children_list.scroller) def load(self, taxon: Taxon): diff --git a/naturtag/metadata/__init__.py b/naturtag/metadata/__init__.py index 427209ba..f32c59e5 100644 --- a/naturtag/metadata/__init__.py +++ b/naturtag/metadata/__init__.py @@ -7,10 +7,8 @@ from naturtag.metadata.keyword_metadata import KeywordMetadata from naturtag.metadata.meta_metadata import MetaMetadata from naturtag.metadata.inat_metadata import ( - get_ids_from_url, - get_inat_metadata, + observation_to_metadata, _refresh_tags, refresh_tags, - strip_url, tag_images, ) diff --git a/naturtag/metadata/inat_metadata.py b/naturtag/metadata/inat_metadata.py index 08ab6381..a26e0b04 100644 --- a/naturtag/metadata/inat_metadata.py +++ b/naturtag/metadata/inat_metadata.py @@ -5,16 +5,15 @@ # TODO: Include eol:dataObject info (metadata for an individual observation photo) from logging import getLogger from typing import Iterable, Optional -from urllib.parse import urlparse from pyinaturalist import Observation, Taxon from pyinaturalist_convert import to_dwc -from naturtag.client import INAT_CLIENT -from naturtag.constants import COMMON_NAME_IGNORE_TERMS, COMMON_RANKS, IntTuple, PathOrStr +from naturtag.client import iNatDbClient +from naturtag.constants import COMMON_NAME_IGNORE_TERMS, COMMON_RANKS, PathOrStr from naturtag.metadata import MetaMetadata from naturtag.settings import Settings -from naturtag.utils import get_valid_image_paths +from naturtag.utils import get_valid_image_paths, quote DWC_NAMESPACES = ['dcterms', 'dwc'] logger = getLogger().getChild(__name__) @@ -26,6 +25,7 @@ def tag_images( taxon_id: Optional[int] = None, recursive: bool = False, include_sidecars: bool = False, + client: Optional[iNatDbClient] = None, settings: Optional[Settings] = None, ) -> list[MetaMetadata]: """ @@ -56,16 +56,18 @@ def tag_images( Updated image metadata for each image """ settings = settings or Settings.read() - inat_metadata = get_inat_metadata( - observation_id=observation_id, - taxon_id=taxon_id, + client = client or iNatDbClient(settings.db_path) + + observation = client.from_ids(observation_id, taxon_id) + if not observation: + return [] + + inat_metadata = observation_to_metadata( + observation, common_names=settings.common_names, hierarchical=settings.hierarchical, ) - - if not inat_metadata: - return [] - elif not image_paths: + if not image_paths: return [inat_metadata] def _tag_image( @@ -91,62 +93,116 @@ def _tag_image( ] -def get_inat_metadata( - observation_id: Optional[int] = None, - taxon_id: Optional[int] = None, - common_names: bool = False, - hierarchical: bool = False, - metadata: Optional[MetaMetadata] = None, -) -> Optional[MetaMetadata]: - """Create or update image metadata based on an iNaturalist observation and/or taxon""" - metadata = metadata or MetaMetadata() - observation, taxon = None, None +def refresh_tags( + image_paths: Iterable[PathOrStr], + recursive: bool = False, + client: Optional[iNatDbClient] = None, + settings: Optional[Settings] = None, +) -> list[MetaMetadata]: + """Refresh metadata for previously tagged images with latest observation and/or taxon data. - # Get observation and/or taxon records - if observation_id: - observation = INAT_CLIENT.observations(observation_id, refresh=True) - taxon_id = observation.taxon.id + Example: - # Observation.taxon doesn't include ancestors, so we always need to fetch the full taxon record - taxon = INAT_CLIENT.taxa(taxon_id, refresh=True) - if not taxon: - logger.warning(f'No taxon found: {taxon_id}') + >>> # Refresh previously tagged images with latest observation and taxonomy metadata + >>> from naturtag import refresh_tags + >>> refresh_tags(['~/observations/'], recursive=True) + + Args: + image_paths: Paths to images to tag + recursive: Recursively search subdirectories for valid image files + settings: Settings for metadata types to generate + + Returns: + Updated metadata objects for updated images only + """ + settings = settings or Settings.read() + client = client or iNatDbClient(settings.db_path) + metadata_objs = [ + _refresh_tags(MetaMetadata(image_path), client, settings) + for image_path in get_valid_image_paths( + image_paths, recursive, create_sidecars=settings.sidecar + ) + ] + return [m for m in metadata_objs if m] + + +def _refresh_tags( + metadata: MetaMetadata, client: iNatDbClient, settings: Settings +) -> Optional[MetaMetadata]: + """Refresh existing metadata for a single image + + Returns: + Updated metadata if existing IDs were found, otherwise ``None`` + """ + if not metadata.has_observation and not metadata.has_taxon: + logger.debug(f'No IDs found in {metadata.image_path}') return None - # If there's a taxon only (no observation), check for any taxonomy changes - if ( - not observation_id - and not taxon.is_active - and len(taxon.current_synonymous_taxon_ids or []) == 1 - ): - taxon = INAT_CLIENT.taxa(taxon.current_synonymous_taxon_ids[0], refresh=True) + logger.debug(f'Refreshing tags for {metadata.image_path}') + settings = settings or Settings.read() + observation = client.from_ids(metadata.observation_id, metadata.taxon_id) + metadata = observation_to_metadata( + observation, + common_names=settings.common_names, + hierarchical=settings.hierarchical, + metadata=metadata, + ) + + metadata.write( + write_exif=settings.exif, + write_iptc=settings.iptc, + write_xmp=settings.xmp, + write_sidecar=settings.sidecar, + ) + return metadata + + +def observation_to_metadata( + observation: Observation, + metadata: Optional[MetaMetadata] = None, + common_names: bool = False, + hierarchical: bool = False, +) -> MetaMetadata: + """Get image metadata from an Observation object""" + metadata = metadata or MetaMetadata() # Get all specified keyword categories - keywords = _get_taxonomy_keywords(taxon) + keywords = _get_taxonomy_keywords(observation.taxon) if hierarchical: - keywords.extend(_get_taxon_hierarchical_keywords(taxon)) + keywords.extend(_get_taxon_hierarchical_keywords(observation.taxon)) if common_names: - common_keywords = _get_common_keywords(taxon) + common_keywords = _get_common_keywords(observation.taxon) keywords.extend(common_keywords) if hierarchical: keywords.extend(_get_hierarchical_keywords(common_keywords)) - keywords.extend(_get_id_keywords(observation_id, taxon_id)) + keywords.extend(_get_id_keywords(observation.id, observation.taxon.id)) logger.debug(f'{len(keywords)} total keywords generated') metadata.update_keywords(keywords) # Convert and add coordinates + # TODO: Add other metdata like title, description, tags, etc. if observation: metadata.update_coordinates(observation.location, observation.positional_accuracy) + def _format_key(k): + """Get DwC terms as XMP tags. + Note: exiv2 will automatically add recognized XML namespace URLs when adding properties + """ + namespace, term = k.split(':') + return f'Xmp.{namespace}.{term}' if namespace in DWC_NAMESPACES else None + # Convert and add DwC metadata - metadata.update(_get_dwc_terms(observation, taxon)) + dwc = to_dwc(observations=[observation], taxa=[observation.taxon])[0] + dwc_xmp = {_format_key(k): v for k, v in dwc.items() if _format_key(k)} + metadata.update(dwc_xmp) + return metadata def _get_taxonomy_keywords(taxon: Taxon) -> list[str]: """Format a list of taxa into rank keywords""" - return [_quote(f'taxonomy:{t.rank}={t.name}') for t in taxon.ancestors + [taxon]] + return [quote(f'taxonomy:{t.rank}={t.name}') for t in taxon.ancestors + [taxon]] def _get_id_keywords( @@ -173,7 +229,7 @@ def _get_common_keywords(taxon: Taxon) -> list[str]: def is_ignored(kw): return any([ignore_term in kw.lower() for ignore_term in COMMON_NAME_IGNORE_TERMS]) - return [_quote(kw) for kw in keywords if kw and not is_ignored(kw)] + return [quote(kw) for kw in keywords if kw and not is_ignored(kw)] def _get_taxon_hierarchical_keywords(taxon: Taxon) -> list[str]: @@ -188,111 +244,3 @@ def _get_hierarchical_keywords(keywords: list[str]) -> list[str]: for k in keywords[1:]: hier_keywords.append(f'{hier_keywords[-1]}|{k}') return hier_keywords - - -def _get_dwc_terms( - observation: Optional[Observation] = None, taxon: Optional[Taxon] = None -) -> dict[str, str]: - """Convert either an observation or taxon into XMP-formatted Darwin Core terms""" - - # Get terms only for specific namespaces - # Note: exiv2 will automatically add recognized XML namespace URLs when adding properties - def format_key(k): - namespace, term = k.split(':') - return f'Xmp.{namespace}.{term}' if namespace in DWC_NAMESPACES else None - - # Convert to DwC, then to XMP tags - dwc = to_dwc(observations=observation, taxa=taxon)[0] - return {format_key(k): v for k, v in dwc.items() if format_key(k)} - - -def get_ids_from_url(url: str) -> IntTuple: - """If a URL is provided containing an ID, return the taxon or observation ID. - - Returns: - ``(observation_id, taxon_id)`` - """ - observation_id, taxon_id = None, None - id = strip_url(url) - - if 'observation' in url: - observation_id = id - elif 'taxa' in url: - taxon_id = id - - return observation_id, taxon_id - - -def refresh_tags( - image_paths: Iterable[PathOrStr], - recursive: bool = False, - settings: Optional[Settings] = None, -) -> list[MetaMetadata]: - """Refresh metadata for previously tagged images - - Example: - - >>> # Refresh previously tagged images with latest observation and taxonomy metadata - >>> from naturtag import refresh_tags - >>> refresh_tags(['~/observations/'], recursive=True) - - Args: - image_paths: Paths to images to tag - recursive: Recursively search subdirectories for valid image files - settings: Settings for metadata types to generate - - Returns: - Updated metadata objects for updated images only - """ - settings = settings or Settings.read() - metadata_objs = [ - _refresh_tags(MetaMetadata(image_path), settings) - for image_path in get_valid_image_paths( - image_paths, recursive, create_sidecars=settings.sidecar - ) - ] - return [m for m in metadata_objs if m] - - -def _refresh_tags( - metadata: MetaMetadata, settings: Optional[Settings] = None -) -> Optional[MetaMetadata]: - """Refresh existing metadata for a single image with latest observation and/or taxon data. - - Returns: - Updated metadata if existing IDs were found, otherwise ``None`` - """ - if not metadata.has_observation and not metadata.has_taxon: - logger.debug(f'No IDs found in {metadata.image_path}') - return None - - logger.debug(f'Refreshing tags for {metadata.image_path}') - settings = settings or Settings.read() - metadata = get_inat_metadata( # type: ignore - observation_id=metadata.observation_id, - taxon_id=metadata.taxon_id, - common_names=settings.common_names, - hierarchical=settings.hierarchical, - metadata=metadata, - ) - metadata.write( - write_exif=settings.exif, - write_iptc=settings.iptc, - write_xmp=settings.xmp, - write_sidecar=settings.sidecar, - ) - return metadata - - -def strip_url(value: str) -> Optional[int]: - """If a URL is provided containing an ID, return just the ID""" - try: - path = urlparse(value).path - return int(path.split('/')[-1].split('-')[0]) - except (TypeError, ValueError): - return None - - -def _quote(s: str) -> str: - """Surround keyword in quotes if it contains whitespace""" - return f'"{s}"' if ' ' in s else s diff --git a/naturtag/utils/__init__.py b/naturtag/utils/__init__.py index bf7b780a..e5d6a17f 100644 --- a/naturtag/utils/__init__.py +++ b/naturtag/utils/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa: F401 from naturtag.utils.i18n import read_locales from naturtag.utils.image_glob import get_valid_image_paths +from naturtag.utils.parsing import get_ids_from_url, quote, strip_url from naturtag.utils.thumbnails import generate_thumbnail diff --git a/naturtag/utils/parsing.py b/naturtag/utils/parsing.py new file mode 100644 index 00000000..24c8e6e2 --- /dev/null +++ b/naturtag/utils/parsing.py @@ -0,0 +1,38 @@ +"""Misc URL and string parsing utilities""" + + +from typing import Optional +from urllib.parse import urlparse + +from naturtag.constants import IntTuple + + +def get_ids_from_url(url: str) -> IntTuple: + """If a URL is provided containing an ID, return the taxon or observation ID. + + Returns: + ``(observation_id, taxon_id)`` + """ + observation_id, taxon_id = None, None + id = strip_url(url) + + if 'observation' in url: + observation_id = id + elif 'taxa' in url: + taxon_id = id + + return observation_id, taxon_id + + +def strip_url(value: str) -> Optional[int]: + """If a URL is provided containing an ID, return just the ID""" + try: + path = urlparse(value).path + return int(path.split('/')[-1].split('-')[0]) + except (TypeError, ValueError): + return None + + +def quote(s: str) -> str: + """Surround keyword in quotes if it contains whitespace""" + return f'"{s}"' if ' ' in s else s diff --git a/naturtag/widgets/__init__.py b/naturtag/widgets/__init__.py index 670c308f..39ab827d 100644 --- a/naturtag/widgets/__init__.py +++ b/naturtag/widgets/__init__.py @@ -19,6 +19,8 @@ InfoCardList, ImageWindow, PixmapLabel, + set_pixmap, + set_pixmap_async, ) from naturtag.widgets.inputs import IdInput from naturtag.widgets.logger import QtRichHandler, init_handler diff --git a/naturtag/widgets/autocomplete.py b/naturtag/widgets/autocomplete.py index a9878edb..ed6d0427 100644 --- a/naturtag/widgets/autocomplete.py +++ b/naturtag/widgets/autocomplete.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import QCompleter, QLineEdit, QToolButton from naturtag.app.style import fa_icon -from naturtag.settings import Settings +from naturtag.controllers import get_app logger = getLogger(__name__) @@ -18,11 +18,10 @@ class TaxonAutocomplete(QLineEdit): on_select = Signal(int) #: An autocomplete result was selected on_tab = Signal() #: Tab key was pressed - def __init__(self, settings: Settings): + def __init__(self): super().__init__() self.setClearButtonEnabled(True) self.findChild(QToolButton).setIcon(fa_icon('mdi.backspace')) - self.settings = settings self.taxa: dict[str, int] = {} completer = QCompleter() @@ -32,7 +31,7 @@ def __init__(self, settings: Settings): self.on_tab.connect(self.next_result) # Results are fetched from FTS5, and passed to the completer via an intermediate model - self.taxon_completer = TaxonAutocompleter(settings.db_path) + self.taxon_completer = TaxonAutocompleter(get_app().settings.db_path) self.textChanged.connect(self.search) self.model = QStringListModel() completer.activated.connect(self.select_taxon) @@ -53,7 +52,8 @@ def next_result(self): # TODO: Input delay def search(self, q: str): if len(q) > 1 and q not in self.taxa: - language = self.settings.locale if self.settings.search_locale else None + app = get_app() + language = app.settings.locale if app.settings.search_locale else None results = self.taxon_completer.search(q, language=language) self.taxa = {t.name: t.id for t in results} self.model.setStringList(self.taxa.keys()) diff --git a/naturtag/widgets/images.py b/naturtag/widgets/images.py index a7c412f8..d981b73d 100644 --- a/naturtag/widgets/images.py +++ b/naturtag/widgets/images.py @@ -5,20 +5,16 @@ from pathlib import Path from typing import TYPE_CHECKING, Iterator, Optional, TypeAlias, Union -from pyinaturalist import Photo from PySide6.QtCore import QSize, Qt, QThread, Signal from PySide6.QtGui import QBrush, QFont, QIcon, QPainter, QPixmap from PySide6.QtWidgets import QLabel, QLayout, QScrollArea, QSizePolicy, QWidget from naturtag.app.style import fa_icon -from naturtag.client import IMG_SESSION from naturtag.constants import SIZE_ICON, SIZE_ICON_SM, SIZE_SM, IconDimensions, IntOrStr, PathOrStr from naturtag.widgets import StylableWidget, VerticalLayout from naturtag.widgets.layouts import GridLayout, HorizontalLayout if TYPE_CHECKING: - from naturtag.app.threadpool import ThreadPool - MIXIN_BASE: TypeAlias = QWidget else: MIXIN_BASE = object @@ -26,6 +22,28 @@ logger = getLogger(__name__) +def set_pixmap_async( + pixmap_label: QLabel, + priority: QThread.Priority = QThread.NormalPriority, + **kwargs, +): + """Fetch an image from a separate thread, and render it in the main thread when complete""" + from naturtag.controllers import get_app + + app = get_app() + future = app.threadpool.schedule(app.img_session.get_pixmap, priority=priority, **kwargs) + future.on_result.connect(pixmap_label.setPixmap) + + +def set_pixmap(pixmap_label: QLabel, *args, **kwargs): + """Fetch an image from either a local path or remote URL.""" + from naturtag.controllers import get_app + + app = get_app() + pixmap = app.img_session.get_pixmap(*args, **kwargs) + pixmap_label.setPixmap(pixmap) + + class FAIcon(QLabel): """A QLabel for displaying a FontAwesome icon""" @@ -131,42 +149,14 @@ def __init__( self.rounded = rounded self.scale = scale self.xform = Qt.SmoothTransformation if resample else Qt.FastTransformation - if path or url: - pixmap = self.get_pixmap(path=path, url=url) self.setPixmap(pixmap) - - # TODO: Need quite a bit of refactoring to not depend on global session object - # Pass app.img_session as a parameter instead - def get_pixmap(self, *args, **kwargs) -> QPixmap: - """Fetch a pixmap from either a local path or remote URL. - This does not render the image, so it is safe to run from any thread. - """ - return IMG_SESSION.get_pixmap(*args, **kwargs) + if path or url: + set_pixmap(self, path=path, url=url) def setPixmap(self, pixmap: QPixmap): self._pixmap = pixmap super().setPixmap(self.scaledPixmap()) - def set_pixmap_async( - self, - threadpool: 'ThreadPool', - priority: QThread.Priority = QThread.NormalPriority, - path: Optional[PathOrStr] = None, - photo: Optional[Photo] = None, - size: str = 'medium', - url: Optional[str] = None, - ): - """Fetch a photo from a separate thread, and render it in the main thread when complete""" - future = threadpool.schedule( - self.get_pixmap, - priority=priority, - path=path, - photo=photo, - url=url, - size=size, - ) - future.on_result.connect(self.setPixmap) - def clear(self): self.setPixmap(QPixmap()) @@ -370,9 +360,8 @@ def mouseReleaseEvent(self, event): class InfoCardList(StylableWidget): """A scrollable list of InfoCards""" - def __init__(self, threadpool: 'ThreadPool', parent: Optional[QWidget] = None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) - self.threadpool = threadpool self.root = VerticalLayout(self) self.root.setAlignment(Qt.AlignTop) self.root.setContentsMargins(0, 5, 5, 0) @@ -394,7 +383,7 @@ def add_card(self, card: InfoCard, thumbnail_url: str, idx: Optional[int] = None self.root.insertWidget(idx, card) else: self.root.addWidget(card) - card.thumbnail.set_pixmap_async(self.threadpool, url=thumbnail_url) + set_pixmap_async(card.thumbnail, url=thumbnail_url) def clear(self): self.root.clear() diff --git a/naturtag/widgets/logger.py b/naturtag/widgets/logger.py index 1331725d..93f4b8b0 100644 --- a/naturtag/widgets/logger.py +++ b/naturtag/widgets/logger.py @@ -22,7 +22,7 @@ def init_handler( root.setLevel(root_level) root.addHandler(qt_handler) - # iI a logfile is specified, add a FileHandler with a separate formatter + # If a logfile is specified, add a FileHandler with a separate formatter if logfile: handler = FileHandler(filename=str(logfile)) handler.setFormatter( diff --git a/naturtag/widgets/observation_images.py b/naturtag/widgets/observation_images.py index cfafaebb..980253ed 100644 --- a/naturtag/widgets/observation_images.py +++ b/naturtag/widgets/observation_images.py @@ -7,7 +7,14 @@ from PySide6.QtCore import Qt from naturtag.constants import SIZE_ICON_SM -from naturtag.widgets.images import HoverPhoto, IconLabel, ImageWindow, InfoCard, InfoCardList +from naturtag.widgets.images import ( + HoverPhoto, + IconLabel, + ImageWindow, + InfoCard, + InfoCardList, + set_pixmap, +) from naturtag.widgets.layouts import HorizontalLayout logger = getLogger(__name__) @@ -41,8 +48,7 @@ def __init__(self, obs: Observation, delayed_load: bool = True): self.observation = obs if not delayed_load: - pixmap = self.thumbnail.get_pixmap(url=obs.default_photo.thumbnail_url) - self.thumbnail.setPixmap(pixmap) + set_pixmap(self.thumbnail, url=obs.default_photo.thumbnail_url) # Title: Taxon name if obs.taxon: @@ -173,7 +179,7 @@ def select_image_idx(self, idx: int): self.set_photo(self.selected_photo) def set_photo(self, photo: Photo): - self.image.setPixmap(self.image.get_pixmap(url=photo.original_url)) + set_pixmap(self.image, url=photo.original_url) def remove_image(self): pass diff --git a/naturtag/widgets/taxon_images.py b/naturtag/widgets/taxon_images.py index 299a4a03..e5dc8624 100644 --- a/naturtag/widgets/taxon_images.py +++ b/naturtag/widgets/taxon_images.py @@ -16,10 +16,10 @@ ImageWindow, InfoCard, InfoCardList, + set_pixmap, ) if TYPE_CHECKING: - from naturtag.app.threadpool import ThreadPool from naturtag.settings import UserTaxa ATTRIBUTION_STRIP_PATTERN = re.compile(r',?\s+uploaded by.*') @@ -43,8 +43,7 @@ def __init__(self, taxon: Taxon, user_observations_count: int = 0, delayed_load: self.setFixedHeight(90) self.taxon = taxon if not delayed_load: - pixmap = self.thumbnail.get_pixmap(url=taxon.default_photo.thumbnail_url) - self.thumbnail.setPixmap(pixmap) + set_pixmap(self.thumbnail, url=taxon.default_photo.thumbnail_url) # Details self.title.setText(f'{taxon.rank.title()}: {taxon.name}') @@ -65,8 +64,8 @@ def __init__(self, taxon: Taxon, user_observations_count: int = 0, delayed_load: class TaxonList(InfoCardList): """A scrollable list of TaxonInfoCards""" - def __init__(self, threadpool: 'ThreadPool', user_taxa: 'UserTaxa', **kwargs): - super().__init__(threadpool, **kwargs) + def __init__(self, user_taxa: 'UserTaxa', **kwargs): + super().__init__(**kwargs) self.user_taxa = user_taxa def add_taxon(self, taxon: Taxon, idx: Optional[int] = None) -> TaxonInfoCard: @@ -129,7 +128,7 @@ def select_image_idx(self, idx: int): self.set_photo(self.selected_photo) def set_photo(self, photo: Photo): - self.image.setPixmap(self.image.get_pixmap(url=photo.original_url)) + set_pixmap(self.image, url=photo.original_url) attribution = ( ATTRIBUTION_STRIP_PATTERN.sub('', photo.attribution or '') .replace('(c)', '©')