Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Main app refactoring #346

Merged
merged 3 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions naturtag/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# flake8: noqa: F401
from naturtag.app.app import NaturtagApp
72 changes: 43 additions & 29 deletions naturtag/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -154,15 +168,15 @@ 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
self.observation_controller.select_observation(56830941)

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):
Expand Down Expand Up @@ -191,7 +205,7 @@ def open_about(self):
repo_link = f"<a href='{REPO_URL}'>{REPO_URL}</a>"
license_link = f"<a href='{REPO_URL}/LICENSE'>MIT License</a>"
attribution = f'Ⓒ {datetime.now().year} Jordan Cook, {license_link}'
app_dir_link = f"<a href='{self.settings.data_dir}'>{self.settings.data_dir}</a>"
app_dir_link = f"<a href='{self.app.settings.data_dir}'>{self.app.settings.data_dir}</a>"

about.setText(
f'<b>Naturtag v{version}</b><br/>'
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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())
Expand Down
82 changes: 60 additions & 22 deletions naturtag/app/settings_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -105,15 +143,15 @@ 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)
)

# 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',
)
Expand All @@ -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'],
Expand All @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions naturtag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'`([^`]+?)`')
Expand Down
33 changes: 28 additions & 5 deletions naturtag/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Loading