diff --git a/Mergin/clone_project_dialog.py b/Mergin/clone_project_dialog.py index 77bcf53a..a4381b95 100644 --- a/Mergin/clone_project_dialog.py +++ b/Mergin/clone_project_dialog.py @@ -1,29 +1,68 @@ import os -from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QApplication, QMessageBox + +from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QApplication, QMessageBox, QComboBox +from qgis.PyQt.QtCore import Qt from qgis.PyQt import uic ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_clone_project.ui") class CloneProjectDialog(QDialog): - def __init__(self, username, user_organisations=None): + """Dialog for cloning remote projects. Allows selection of workspace/namespace and project name""" + + def __init__(self, user_info, default_workspace=None): + """Create a dialog for cloning remote projects + + :param user_info: The user_info dictionary as returned from server + :param default_workspace: Optionally, the name of the current workspace so it can be pre-selected in the list + """ QDialog.__init__(self) self.ui = uic.loadUi(ui_file, self) self.ui.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) self.ui.buttonBox.accepted.connect(self.accept_dialog) - self.ui.projectNamespace.addItem(username) - if user_organisations: - self.ui.projectNamespace.addItems( - [o for o in user_organisations if user_organisations[o] in ["admin", "owner"]] - ) + + workspaces = user_info.get("workspaces", None) + if workspaces is not None: + for ws in workspaces: + is_writable = ws.get("role", "owner") in ["owner", "admin", "writer"] + self.ui.projectNamespace.addItem(ws["name"], is_writable) + + else: + # This means server is old and uses namespaces + self.ui.projectNamespaceLabel.setText("Owner") + username = user_info["username"] + user_organisations = user_info.get("organisations", []) + self.ui.projectNamespace.addItem(username, True) + for o in user_organisations: + if user_organisations[o] in ["owner", "admin", "writer"]: + self.ui.projectNamespace.addItem(o, True) + + self.ui.projectNamespace.currentTextChanged.connect(self.workspace_changed) self.ui.edit_project_name.textChanged.connect(self.text_changed) + # disable widgets if default workspace is read only + self.workspace_changed() + self.ui.projectNamespace.setCurrentText(default_workspace) + # these are the variables used by the caller self.project_name = None self.project_namespace = None def text_changed(self): - self.ui.buttonBox.button(QDialogButtonBox.Ok).setEnabled(bool(self.ui.edit_project_name.text())) + enabled = bool(self.ui.edit_project_name.text()) and not self.ui.warningMessageLabel.isVisible() + self.ui.buttonBox.button(QDialogButtonBox.Ok).setEnabled(enabled) + + def workspace_changed(self): + is_writable = bool(self.ui.projectNamespace.currentData(Qt.UserRole)) + if is_writable: + msg = "" + else: + msg = "You do not have permissions to create a project in this workspace!" + self.ui.edit_project_name.setToolTip(msg) + self.ui.buttonBox.button(QDialogButtonBox.Ok).setToolTip(msg) + self.ui.warningMessageLabel.setVisible(not is_writable) + self.ui.warningMessageLabel.setText(msg) + self.ui.buttonBox.button(QDialogButtonBox.Ok).setEnabled(is_writable and bool(self.ui.edit_project_name.text())) def accept_dialog(self): self.project_name = self.ui.edit_project_name.text() diff --git a/Mergin/create_project_wizard.py b/Mergin/create_project_wizard.py index 5f2f6b13..22415947 100644 --- a/Mergin/create_project_wizard.py +++ b/Mergin/create_project_wizard.py @@ -99,6 +99,7 @@ def __init__(self, parent=None): self.path_ledit.setReadOnly(True) self.path_ledit.textChanged.connect(self.check_input) self.project_name_ledit.textChanged.connect(self.check_input) + self.project_owner_cbo.currentTextChanged.connect(self.check_input) def nextId(self): return -1 @@ -112,11 +113,22 @@ def initializePage(self): self.for_current_proj = False def populate_namespace_cbo(self): - self.project_owner_cbo.addItem(self.parent.username) - if self.parent.user_organisations: - self.project_owner_cbo.addItems( - [o for o in self.parent.user_organisations if self.parent.user_organisations[o] in ["admin", "owner"]] - ) + if self.parent.workspaces is not None: + for ws in self.parent.workspaces: + is_writable = ws.get("role", "owner") in ["owner", "admin", "writer"] + self.project_owner_cbo.addItem(ws["name"], is_writable) + + else: + # This means server is old and uses namespaces + self.projectNamespaceLabel.setText("Owner") + username = self.parent.user_info["username"] + user_organisations = self.parent.user_info.get("organisations", []) + self.project_owner_cbo.addItem(username, True) + for o in user_organisations: + if user_organisations[o] in ["owner", "admin", "writer"]: + self.project_owner_cbo.addItem(o, True) + + self.project_owner_cbo.setCurrentText(self.parent.default_workspace) def setup_browsing(self, question=None, current_proj=False, field=None): """This will setup label and signals for browse button.""" @@ -158,6 +170,9 @@ def set_info(self, info=None): def check_input(self): """Check if entered path is not already a Mergin Maps project dir and has at most a single QGIS project file.""" # TODO: check if the project exists on the server + if not self.project_owner_cbo.currentData(Qt.UserRole): + self.create_warning("You do not have permissions to create a project in this workspace!") + return proj_name = self.project_name_ledit.text() if not proj_name: self.create_warning("Project name missing!") @@ -393,7 +408,13 @@ def nextId(self): class NewMerginProjectWizard(QWizard): """Wizard for creating new Mergin Maps project.""" - def __init__(self, project_manager, username, user_organisations=None, parent=None): + def __init__(self, project_manager, user_info, default_workspace=None, parent=None): + """Create a wizard for new Mergin Maps project + + :param project_manager: MerginProjectsManager instance + :param user_info: The user_info dictionary as returned from server + :param default_workspace: Optionally, the name of the current workspace so it can be pre-selected in the list + """ super().__init__(parent) self.iface = iface self.settings = QSettings() @@ -401,8 +422,11 @@ def __init__(self, project_manager, username, user_organisations=None, parent=No self.setWizardStyle(QWizard.ClassicStyle) self.setDefaultProperty("QComboBox", "currentText", QComboBox.currentTextChanged) self.project_manager = project_manager - self.username = username - self.user_organisations = user_organisations + self.username = user_info["username"] + self.user_organisations = user_info.get("organisations", []) + self.workspaces = user_info.get("workspaces", None) + self.default_workspace = default_workspace + self.user_info = user_info self.init_page = InitPage(self) self.setPage(INIT_PAGE, self.init_page) diff --git a/Mergin/images/default/tabler_icons/explore.svg b/Mergin/images/default/tabler_icons/explore.svg new file mode 100644 index 00000000..024bbd8b --- /dev/null +++ b/Mergin/images/default/tabler_icons/explore.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Mergin/images/default/tabler_icons/replace.svg b/Mergin/images/default/tabler_icons/replace.svg new file mode 100644 index 00000000..2aa17dd8 --- /dev/null +++ b/Mergin/images/default/tabler_icons/replace.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Mergin/images/default/tabler_icons/search.svg b/Mergin/images/default/tabler_icons/search.svg new file mode 100644 index 00000000..d9ec0a35 --- /dev/null +++ b/Mergin/images/default/tabler_icons/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 2b314a46..34397b50 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -9,6 +9,7 @@ import shutil from pathlib import Path import posixpath +from functools import partial from qgis.PyQt.QtCore import pyqtSignal, QTimer, QUrl, QSettings, Qt from qgis.PyQt.QtGui import QIcon, QDesktopServices, QPixmap from qgis.core import ( @@ -30,6 +31,8 @@ from urllib.error import URLError from .configuration_dialog import ConfigurationDialog +from .workspace_selection_dialog import WorkspaceSelectionDialog +from .project_selection_dialog import ProjectSelectionDialog, PublicProjectSelectionDialog from .create_project_wizard import NewMerginProjectWizard from .clone_project_dialog import CloneProjectDialog from .diff_dialog import DiffViewerDialog @@ -37,6 +40,7 @@ from .projects_manager import MerginProjectsManager from .sync_dialog import SyncDialog from .utils import ( + ServerType, ClientError, LoginError, check_mergin_subdirs, @@ -73,6 +77,7 @@ def __init__(self, iface): self.mergin_proj_dir = None self.mc = None self.manager = None + self.current_workspace_name = None # This is None if the server does not support workspaces self.provider = MerginProvider() self.toolbar = self.iface.addToolBar("Mergin Maps Toolbar") self.toolbar.setToolTip("Mergin Maps Toolbar") @@ -197,6 +202,7 @@ def create_manager(self): try: if self.mc is None: self.mc = create_mergin_client() + self.choose_active_workspace() self.manager = MerginProjectsManager(self.mc) except (URLError, ClientError, LoginError): error = "Plugin not configured or \nQGIS master password not set up" @@ -221,13 +227,19 @@ def on_config_changed(self): self.enable_toolbar_actions() self.post_login() - def open_configured_url(self): - """Opens configured Mergin Maps server url in default browser""" + def open_configured_url(self, path=None): + """Opens configured Mergin Maps server url in default browser + Use optional parameter path to go directly to a specific page, eg. /workspaces""" if self.mc is None: url = QUrl("https://merginmaps.com") else: url = QUrl(self.mc.url) + if path: + url_path = url.path() + while url_path.endswith("/"): + url_path = url_path[:-1] + url.setPath(f"{url_path}{path}") QDesktopServices.openUrl(url) def enable_toolbar_actions(self, enable=None): @@ -262,6 +274,66 @@ def configure(self): self.on_config_changed() self.show_browser_panel() + def show_no_workspaces_dialog(self): + msg = ( + "Workspace is a place to store your projects and share them with your colleagues. " + "Click on the button below to create one. \n\n" + "A minimum of one workspace is required to use Mergin Maps." + ) + msg_box = QMessageBox(QMessageBox.Critical, "You do not have any workspace", msg, QMessageBox.Close) + create_button = msg_box.addButton("Create workspace", msg_box.ActionRole) + create_button.clicked.disconnect() + create_button.clicked.connect(partial(self.open_configured_url, "/workspaces")) + msg_box.exec_() + + def set_current_workspace(self, workspace): + """ + Sets the current workspace + + :param workspace: Dict containing workspace's "name" and "id" keys + """ + settings = QSettings() + self.current_workspace_name = workspace.get("name", None) + settings.setValue("Mergin/lastUsedWorkspaceId", workspace.get("id", None)) + if self.has_browser_item(): + self.data_item_provider.root_item.update_client_and_manager(mc=self.mc, manager=self.manager) + + def choose_active_workspace(self): + """ + Called after connecting to server. + Chooses and sets the current workspace based on workspace availability and last used workspace. + """ + user_info = self.mc.user_info() + workspaces = user_info.get("workspaces", None) + if not workspaces: + if workspaces is None: + # server is old, does not support workspaces + self.current_workspace_name = None + else: + # User has no workspaces + self.show_no_workspaces_dialog() + self.current_workspace_name = None + return + + if len(workspaces) == 1: + workspace = workspaces[0] + else: + settings = QSettings() + previous_workspace = settings.value("Mergin/lastUsedWorkspaceId", None, int) + workspace = None + for ws in workspaces: + if previous_workspace == ws["id"]: + workspace = ws + break + + if not workspace: + for ws in workspaces: + if user_info["preferred_workspace"] == ws["id"]: + workspace = ws + break + + self.set_current_workspace(workspace) + def post_login(self): """Groups actions that needs to be done when auth information changes""" if not self.mc: @@ -295,19 +367,71 @@ def create_new_project(self): return user_info = self.mc.user_info() - wizard = NewMerginProjectWizard( - self.manager, username=user_info["username"], user_organisations=user_info.get("organisations", []) - ) + workspaces = user_info.get("workspaces", None) + if not workspaces and workspaces is not None: + self.show_no_workspaces_dialog() + self.current_workspace_name = None + return + + default_workspace = self.current_workspace_name + if self.mc.server_type() == ServerType.OLD: + default_workspace = user_info["username"] + + wizard = NewMerginProjectWizard(self.manager, user_info=user_info, default_workspace=default_workspace) if not wizard.exec_(): return # cancelled if self.has_browser_item(): # make sure the item has the link between remote and local project we have just added self.data_item_provider.root_item.depopulate() + self.data_item_provider.root_item.reload() def current_project_sync(self): """Synchronise current Mergin Maps project.""" self.manager.project_status(self.mergin_proj_dir) + def find_project(self): + """Open new Find Mergin Maps project dialog""" + dlg = ProjectSelectionDialog(self.mc, self.current_workspace_name) + dlg.new_project_clicked.connect(self.create_new_project) + dlg.switch_workspace_clicked.connect(self.switch_workspace) + dlg.open_project_clicked.connect(self.manager.open_project) + dlg.download_project_clicked.connect(self.manager.download_project) + + try: + workspaces = self.mc.workspaces_list() + dlg.enable_workspace_switching(len(workspaces) > 1) + except: + pass + + dlg.exec_() + + def switch_workspace(self): + """Open new Switch workspace dialog""" + try: + workspaces = self.mc.workspaces_list() + except (URLError, ClientError) as e: + return # Server does not support workspaces + + if not workspaces: + self.show_no_workspaces_dialog() + self.current_workspace_name = None + return + + dlg = WorkspaceSelectionDialog(workspaces) + dlg.manage_workspaces_clicked.connect(self.open_configured_url) + if not dlg.exec_(): + return + + workspace = dlg.get_workspace() + self.set_current_workspace(workspace) + + def explore_public_projects(self): + """Open new Explore public Mergin Maps projects dialog""" + dlg = PublicProjectSelectionDialog(self.mc) + dlg.open_project_clicked.connect(self.manager.open_project) + dlg.download_project_clicked.connect(self.manager.download_project) + dlg.exec_() + def on_qgis_project_changed(self): """ Called when QGIS project is created or (re)loaded. Sets QGIS project related Mergin Maps variables. @@ -395,7 +519,11 @@ def __init__(self, parent, project, project_manager): self.project_name = posixpath.join( project["namespace"], project["name"] ) # we need posix path for server API calls - QgsDataItem.__init__(self, QgsDataItem.Collection, parent, self.project_name, "/Mergin/" + self.project_name) + display_name = project["name"] + group_items = project_manager.get_mergin_browser_groups() + if group_items.get("Shared with me") == parent: + display_name = self.project_name + QgsDataItem.__init__(self, QgsDataItem.Collection, parent, display_name, "/Mergin/" + self.project_name) self.path = None self.setSortKey(f"1 {self.name()}") self.setIcon(QIcon(icon_path("cloud.svg"))) @@ -406,62 +534,16 @@ def __init__(self, parent, project, project_manager): self.mc = None def download(self): - settings = QSettings() - last_parent_dir = settings.value("Mergin/lastUsedDownloadDir", str(Path.home())) - parent_dir = QFileDialog.getExistingDirectory(None, "Open Directory", last_parent_dir, QFileDialog.ShowDirsOnly) - if not parent_dir: - return - settings.setValue("Mergin/lastUsedDownloadDir", parent_dir) - target_dir = os.path.abspath(os.path.join(parent_dir, self.project["name"])) - if os.path.exists(target_dir): - QMessageBox.warning( - None, - "Download Project", - "The target directory already exists:\n" + target_dir + "\n\nPlease select a different directory.", - ) - return - - dlg = SyncDialog() - dlg.download_start(self.mc, target_dir, self.project_name) - dlg.exec_() # blocks until completion / failure / cancellation - if dlg.exception: - if isinstance(dlg.exception, (URLError, ValueError)): - QgsApplication.messageLog().logMessage("Mergin Maps plugin: " + str(dlg.exception)) - msg = ( - "Failed to download your project {}.\n" - "Please make sure your Mergin Maps settings are correct".format(self.project_name) - ) - QMessageBox.critical(None, "Project download", msg, QMessageBox.Close) - elif isinstance(dlg.exception, LoginError): - login_error_message(dlg.exception) - else: - unhandled_exception_message( - dlg.exception_details(), - "Project download", - f"Failed to download project {self.project_name} due to an unhandled exception.", - ) - return - if not dlg.is_complete: - return # either it has been cancelled or an error has been thrown - - settings.setValue("Mergin/localProjects/{}/path".format(self.project_name), target_dir) - self.path = target_dir - msg = "Your project {} has been successfully downloaded. " "Do you want to open project file?".format( - self.project_name - ) - btn_reply = QMessageBox.question( - None, "Project download", msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes - ) - if btn_reply == QMessageBox.Yes: - self.open_project() - self.parent().reload() + self.project_manager.download_project(self.project) + return def open_project(self): self.project_manager.open_project(self.path) def clone_remote_project(self): user_info = self.mc.user_info() - dlg = CloneProjectDialog(username=user_info["username"], user_organisations=user_info.get("organisations", [])) + + dlg = CloneProjectDialog(user_info=user_info, default_workspace=self.project["namespace"]) if not dlg.exec_(): return # cancelled try: @@ -520,8 +602,12 @@ class MerginLocalProjectItem(QgsDirectoryItem): def __init__(self, parent, project, project_manager): self.project_name = posixpath.join(project["namespace"], project["name"]) # posix path for server API calls self.path = mergin_project_local_path(self.project_name) - QgsDirectoryItem.__init__(self, parent, self.project_name, self.path, "/Mergin/" + self.project_name) - self.setSortKey(f"1 {self.name()}") + display_name = project["name"] + group_items = project_manager.get_mergin_browser_groups() + if group_items.get("Shared with me") == parent: + display_name = self.project_name + QgsDirectoryItem.__init__(self, parent, display_name, self.path, "/Mergin/" + self.project_name) + self.setSortKey(f"0 {self.name()}") self.project = project self.project_manager = project_manager if self.project_manager is not None: @@ -609,7 +695,9 @@ def submit_logs(self): def clone_remote_project(self): user_info = self.mc.user_info() - dlg = CloneProjectDialog(username=user_info["username"], user_organisations=user_info.get("organisations", [])) + + dlg = CloneProjectDialog(user_info=user_info, default_workspace=self.project["namespace"]) + if not dlg.exec_(): return # cancelled try: @@ -663,44 +751,73 @@ def handleDoubleClick(self): return True -class MerginGroupItem(QgsDataCollectionItem): - """Mergin group data item. Contains filtered list of Mergin Maps projects.""" +class MerginRootItem(QgsDataCollectionItem): + """Mergin root data containing project groups item with configuration dialog.""" - def __init__(self, parent, grp_name, grp_filter, icon, order, plugin): - QgsDataCollectionItem.__init__(self, parent, grp_name, "/Mergin" + grp_name) - self.filter = grp_filter + local_project_removed = pyqtSignal() + + def __init__( + self, + parent=None, + name="Mergin Maps", + flag=None, + icon="mm_icon_positive_no_padding.svg", + order=None, + plugin=None, + ): + providerKey = "Mergin Maps" + if name != providerKey: + providerKey = "/Mergin" + name + QgsDataCollectionItem.__init__(self, parent, name, providerKey) self.setIcon(QIcon(icon_path(icon))) self.setSortKey(order) self.plugin = plugin self.project_manager = plugin.manager + self.mc = self.project_manager.mc if self.project_manager is not None else None + self.error = "" + self.wizard = None self.projects = [] - self.group_name = grp_name self.total_projects_count = None self.fetch_more_item = None + self.filter = flag + self.base_name = self.name() + self.updateName() - def fetch_projects(self, page=1, per_page=PROJS_PER_PAGE): - """Get paginated projects list from Mergin Maps service. If anything goes wrong, return an error item.""" - if self.project_manager is None: - error_item = QgsErrorItem(self, "Failed to log in. Please check the configuration", "/Mergin/error") - sip.transferto(error_item, self) - return [error_item] - try: - resp = self.project_manager.mc.paginated_projects_list( - flag=self.filter, page=page, per_page=per_page, order_params="namespace_asc,name_asc" - ) - self.projects += resp["projects"] - self.total_projects_count = int(resp["count"]) if is_number(resp["count"]) else 0 - except URLError: - error_item = QgsErrorItem(self, "Failed to get projects from server", "/Mergin/error") - sip.transferto(error_item, self) - return [error_item] - except Exception as err: - error_item = QgsErrorItem(self, "Error: {}".format(str(err)), "/Mergin/error") + def update_client_and_manager(self, mc=None, manager=None, err=None): + """Update Mergin client and project manager - used when starting or after a config change.""" + self.mc = mc + self.project_manager = manager + self.error = err + self.projects = [] + self.updateName() + # We need to depopulate() so that child groups are refreshed (eg when changing user on old server) + self.depopulate() + # We need to refresh() so that changing from an empty workspace repopulates entries + self.refresh() + + def updateName(self): + if self.mc.server_type() == ServerType.OLD: + name = self.base_name + elif self.plugin.current_workspace_name: + name = f"{self.base_name} [{self.plugin.current_workspace_name}]" + else: + name = self.base_name + self.setName(name) + + def createChildren(self): + if self.error or self.mc is None: + self.error = self.error if self.error else "Not configured!" + error_item = QgsErrorItem(self, self.error, "Mergin/error") + error_item.setIcon(QIcon(icon_path("alert-triangle.svg"))) sip.transferto(error_item, self) return [error_item] - return None - def createChildren(self): + if self.mc.server_type() == ServerType.OLD: + return self.createChildrenGroups() + + return self.createChildrenProjects() + + def createChildrenProjects(self): if not self.projects: error = self.fetch_projects() if error is not None: @@ -721,6 +838,53 @@ def createChildren(self): items.append(self.fetch_more_item) return items + def createChildrenGroups(self): + items = [] + my_projects = MerginGroupItem(self, "My projects", "created", "user.svg", 1, self.plugin) + my_projects.setState(QgsDataItem.Populated) + my_projects.refresh() + sip.transferto(my_projects, self) + items.append(my_projects) + + shared_projects = MerginGroupItem(self, "Shared with me", "shared", "users.svg", 2, self.plugin) + shared_projects.setState(QgsDataItem.Populated) + shared_projects.refresh() + sip.transferto(shared_projects, self) + items.append(shared_projects) + + return items + + def fetch_projects(self, page=1, per_page=PROJS_PER_PAGE): + """Get paginated projects list from Mergin Maps service. If anything goes wrong, return an error item.""" + if self.project_manager is None: + error_item = QgsErrorItem(self, "Failed to log in. Please check the configuration", "/Mergin/error") + sip.transferto(error_item, self) + return [error_item] + if self.mc.server_type() != ServerType.OLD and not self.plugin.current_workspace_name: + error_item = QgsErrorItem(self, "No workspace available", "/Mergin/error") + sip.transferto(error_item, self) + return [error_item] + try: + resp = self.project_manager.mc.paginated_projects_list( + flag=self.filter, + only_namespace=None if self.filter else self.plugin.current_workspace_name, + page=page, + per_page=per_page, + # todo: switch back to "namespace_asc,name_asc" as it currently crashes ee.dev and ce.dev + order_params="name_asc", + ) + self.projects += resp["projects"] + self.total_projects_count = int(resp["count"]) if is_number(resp["count"]) else 0 + except URLError: + error_item = QgsErrorItem(self, "Failed to get projects from server", "/Mergin/error") + sip.transferto(error_item, self) + return [error_item] + except Exception as err: + error_item = QgsErrorItem(self, "Error: {}".format(str(err)), "/Mergin/error") + sip.transferto(error_item, self) + return [error_item] + return None + def set_fetch_more_item(self): """Check if there are more projects to be fetched from Mergin service and set the fetch-more item.""" if self.fetch_more_item is not None: @@ -734,8 +898,9 @@ def set_fetch_more_item(self): self.fetch_more_item = FetchMoreItem(self) self.fetch_more_item.setState(QgsDataItem.Populated) sip.transferto(self.fetch_more_item, self) - group_name = f"{self.group_name} ({self.total_projects_count})" - self.setName(group_name) + if isinstance(self, MerginGroupItem): + group_name = f"{self.base_name} ({self.total_projects_count})" + self.setName(group_name) def fetch_more(self): """Fetch another page of projects and add them to the group item.""" @@ -747,82 +912,74 @@ def fetch_more(self): self.refresh() def reload(self): + if not self.plugin.current_workspace_name: + self.plugin.choose_active_workspace() + self.projects = [] self.refresh() def actions(self, parent): - action_refresh = QAction(QIcon(icon_path("repeat.svg")), "Reload", parent) + action_configure = QAction(QIcon(icon_path("settings.svg")), "Configure", parent) + action_configure.triggered.connect(self.plugin.configure) + + action_refresh = QAction(QIcon(icon_path("repeat.svg")), "Refresh", parent) action_refresh.triggered.connect(self.reload) - actions = [action_refresh] - if self.fetch_more_item is not None: - action_fetch_more = QAction(QIcon(icon_path("dots.svg")), "Fetch more", parent) - action_fetch_more.triggered.connect(self.fetch_more) - actions.append(action_fetch_more) - if self.name().startswith("My projects"): - action_create = QAction(QIcon(icon_path("square-plus.svg")), "Create new project", parent) - action_create.triggered.connect(self.plugin.create_new_project) - actions.append(action_create) - return actions + action_create = QAction(QIcon(icon_path("square-plus.svg")), "Create new project", parent) + action_create.triggered.connect(self.plugin.create_new_project) -class MerginRootItem(QgsDataCollectionItem): - """Mergin root data containing project groups item with configuration dialog.""" + action_find = QAction(QIcon(icon_path("search.svg")), "Find project", parent) + action_find.triggered.connect(self.plugin.find_project) - local_project_removed = pyqtSignal() + action_switch = QAction(QIcon(icon_path("replace.svg")), "Switch workspace", parent) + action_switch.triggered.connect(self.plugin.switch_workspace) - def __init__(self, plugin=None): - QgsDataCollectionItem.__init__(self, None, "Mergin Maps", "Mergin Maps") - self.setIcon(QIcon(icon_path("mm_icon_positive_no_padding.svg"))) - self.plugin = plugin - self.project_manager = plugin.manager - self.mc = self.project_manager.mc if self.project_manager is not None else None - self.error = "" - self.wizard = None + action_explore = QAction(QIcon(icon_path("explore.svg")), "Explore public projects", parent) + action_explore.triggered.connect(self.plugin.explore_public_projects) - def update_client_and_manager(self, mc=None, manager=None, err=None): - """Update Mergin client and project manager - used when starting or after a config change.""" - self.mc = mc - self.project_manager = manager - self.error = err - self.depopulate() + actions = [action_configure] + if self.mc: + server_type = self.mc.server_type() + if server_type == ServerType.OLD: + actions.append(action_create) + actions.append(action_explore) + elif server_type == ServerType.CE: + actions.append(action_refresh) + actions.append(action_create) + actions.append(action_find) + actions.append(action_explore) + elif server_type in (ServerType.EE, ServerType.SAAS): + actions.append(action_refresh) + actions.append(action_create) + actions.append(action_find) + actions.append(action_switch) + actions.append(action_explore) + return actions - def createChildren(self): - if self.error or self.mc is None: - self.error = self.error if self.error else "Not configured!" - error_item = QgsErrorItem(self, self.error, "Mergin/error") - error_item.setIcon(QIcon(icon_path("alert-triangle.svg"))) - sip.transferto(error_item, self) - return [error_item] - items = [] - my_projects = MerginGroupItem(self, "My projects", "created", "user.svg", 1, self.plugin) - my_projects.setState(QgsDataItem.Populated) - my_projects.refresh() - sip.transferto(my_projects, self) - items.append(my_projects) +class MerginGroupItem(MerginRootItem): + """Mergin group data item. Contains filtered list of Mergin Maps projects.""" - shared_projects = MerginGroupItem(self, "Shared with me", "shared", "users.svg", 2, self.plugin) - shared_projects.setState(QgsDataItem.Populated) - shared_projects.refresh() - sip.transferto(shared_projects, self) - items.append(shared_projects) + def __init__(self, parent, grp_name, grp_filter, icon, order, plugin): + MerginRootItem.__init__(self, parent, grp_name, grp_filter, icon, order, plugin) - all_projects = MerginGroupItem(self, "Explore", None, "list.svg", 3, self.plugin) - all_projects.setState(QgsDataItem.Populated) - all_projects.refresh() - sip.transferto(all_projects, self) - items.append(all_projects) + def isMerginGroupItem(self): + return True - return items + def createChildren(self): + return self.createChildrenProjects() def actions(self, parent): - action_configure = QAction(QIcon(icon_path("settings.svg")), "Configure", parent) - action_configure.triggered.connect(self.plugin.configure) - - action_create = QAction(QIcon(icon_path("square-plus.svg")), "Create new project", parent) - action_create.triggered.connect(self.plugin.create_new_project) - actions = [action_configure] - if self.mc: + action_refresh = QAction(QIcon(icon_path("repeat.svg")), "Reload", parent) + action_refresh.triggered.connect(self.reload) + actions = [action_refresh] + if self.fetch_more_item is not None: + action_fetch_more = QAction(QIcon(icon_path("dots.svg")), "Fetch more", parent) + action_fetch_more.triggered.connect(self.fetch_more) + actions.append(action_fetch_more) + if self.name().startswith("My projects"): + action_create = QAction(QIcon(icon_path("square-plus.svg")), "Create new project", parent) + action_create.triggered.connect(self.plugin.create_new_project) actions.append(action_create) return actions @@ -841,7 +998,7 @@ def capabilities(self): def createDataItem(self, path, parentItem): if not parentItem: - ri = MerginRootItem(self.plugin) + ri = MerginRootItem(plugin=self.plugin) sip.transferto(ri, None) self.root_item = ri return ri diff --git a/Mergin/project_selection_dialog.py b/Mergin/project_selection_dialog.py new file mode 100644 index 00000000..5c2be980 --- /dev/null +++ b/Mergin/project_selection_dialog.py @@ -0,0 +1,375 @@ +import os +import posixpath +from enum import Enum, auto +from urllib.error import URLError +from qgis.PyQt.QtWidgets import QDialog, QAbstractItemDelegate, QStyle +from qgis.PyQt.QtCore import ( + QSize, + QSortFilterProxyModel, + Qt, + QModelIndex, + QRect, + QMargins, + pyqtSignal, + QTimer, + QThread, +) +from qgis.PyQt import uic +from qgis.PyQt.QtGui import QPixmap, QFont, QFontMetrics, QIcon, QStandardItem, QStandardItemModel +from qgis.core import ( + QgsApplication, +) +from .mergin.merginproject import MerginProject +from .utils import ( + icon_path, + mergin_project_local_path, + compare_versions, + ClientError, +) + +ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_select_project_dialog.ui") + + +class SyncStatus(Enum): + UP_TO_DATE = auto() + NOT_DOWNLOADED = auto() + LOCAL_CHANGES = auto() + REMOTE_CHANGES = auto() + + +class ProjectsModel(QStandardItemModel): + + PROJECT = Qt.UserRole + 1 + NAME = Qt.UserRole + 2 + NAMESPACE = Qt.UserRole + 3 + NAME_WITH_NAMESPACE = Qt.UserRole + 4 + STATUS = Qt.UserRole + 5 + LOCAL_DIRECTORY = Qt.UserRole + 6 + ICON = Qt.UserRole + 7 + + def __init__(self, projects=None): + super(ProjectsModel, self).__init__() + if projects: + self.appendProjects(projects) + + def appendProjects(self, projects): + for item in self.createItems(projects): + self.appendRow(item) + + @staticmethod + def createItems(projects): + items = [] + for project in projects: + item = QStandardItem(project["name"]) + + status = ProjectsModel.status(project) + if status == SyncStatus.NOT_DOWNLOADED: + status_string = "Not downloaded" + elif status == SyncStatus.LOCAL_CHANGES: + status_string = "Local changes waiting to be pushed" + elif status == SyncStatus.REMOTE_CHANGES: + status_string = "Update available" + else: # status == SyncStatus.UP_TO_DATE: + status_string = "Up to date" + + icon = "" + if status == SyncStatus.NOT_DOWNLOADED: + icon = "cloud-download.svg" + elif status in (SyncStatus.LOCAL_CHANGES, SyncStatus.REMOTE_CHANGES): + icon = "refresh.svg" + + name_with_namespace = f"{project['namespace']}/{project['name']}" + item.setData(name_with_namespace, Qt.DisplayRole) + item.setData(name_with_namespace, ProjectsModel.NAME_WITH_NAMESPACE) + item.setData(project, ProjectsModel.PROJECT) + item.setData(project["name"], ProjectsModel.NAME) + item.setData(project["namespace"], ProjectsModel.NAMESPACE) + item.setData(status_string, ProjectsModel.STATUS) + item.setData(ProjectsModel.localProjectPath(project), ProjectsModel.LOCAL_DIRECTORY) + item.setData(icon, ProjectsModel.ICON) + items.append(item) + return items + + @staticmethod + def localProjectPath(project): + project_name = posixpath.join(project["namespace"], project["name"]) # posix path for server API calls + return mergin_project_local_path(project_name) + + @staticmethod + def status(project): + local_proj_path = ProjectsModel.localProjectPath(project) + if local_proj_path is None or not os.path.exists(local_proj_path): + return SyncStatus.NOT_DOWNLOADED + + mp = MerginProject(local_proj_path) + local_changes = mp.get_push_changes() + if local_changes["added"] or local_changes["removed"] or local_changes["updated"]: + return SyncStatus.LOCAL_CHANGES + elif compare_versions(project["version"], mp.metadata["version"]) > 0: + return SyncStatus.REMOTE_CHANGES + else: + return SyncStatus.UP_TO_DATE + + +class ProjectItemDelegate(QAbstractItemDelegate): + def __init__(self, show_namespace=False): + super(ProjectItemDelegate, self).__init__() + self.show_namespace = show_namespace + + def sizeHint(self, option, index): + fm = QFontMetrics(option.font) + return QSize(150, fm.height() * 3 + fm.leading()) + + def paint(self, painter, option, index): + nameFont = QFont(option.font) + nameFont.setWeight(QFont.Weight.Bold) + fm = QFontMetrics(nameFont) + padding = fm.lineSpacing() // 2 + + nameRect = QRect(option.rect) + nameRect.setLeft(nameRect.left() + padding) + nameRect.setTop(nameRect.top() + padding) + nameRect.setRight(nameRect.right() - 50) + nameRect.setHeight(fm.lineSpacing()) + infoRect = QRect(option.rect) + infoRect.setLeft(infoRect.left() + padding) + infoRect.setTop(infoRect.bottom() - padding - fm.lineSpacing()) + infoRect.setRight(infoRect.right() - 50) + infoRect.setHeight(fm.lineSpacing()) + borderRect = QRect(option.rect.marginsRemoved(QMargins(4, 4, 4, 4))) + iconRect = QRect(borderRect) + iconRect.setLeft(nameRect.right()) + iconRect = iconRect.marginsRemoved(QMargins(12, 12, 12, 12)) + + painter.save() + if option.state & QStyle.State_Selected: + painter.fillRect(borderRect, option.palette.highlight()) + painter.drawRect(borderRect) + painter.setFont(nameFont) + if self.show_namespace: + text = index.data(ProjectsModel.NAME_WITH_NAMESPACE) + else: + text = index.data(ProjectsModel.NAME) + elided_text = fm.elidedText(text, Qt.ElideRight, nameRect.width()) + painter.drawText(nameRect, Qt.AlignLeading, elided_text) + painter.setFont(option.font) + fm = QFontMetrics(QFont(option.font)) + elided_status = fm.elidedText(index.data(ProjectsModel.STATUS), Qt.ElideRight, infoRect.width()) + painter.drawText(infoRect, Qt.AlignLeading, elided_status) + icon = index.data(ProjectsModel.ICON) + if icon: + icon = QIcon(icon_path(icon)) + icon.paint(painter, iconRect) + painter.restore() + + +class ResultFetcher(QThread): + """ + Class to handle fetching paginated server searches in background worker thread + """ + + finished = pyqtSignal(dict) + + def __init__(self, mc, namespace, page, name): + """ + ResultFetcher constructor + + :param mc: MerginClient instance + :param namespace: namespace to filter by + :param page: results page to fetch + :param name: name to filter by + """ + super(ResultFetcher, self).__init__() + self.mc = mc + self.namespace = namespace + self.page = page + self.name = name + + def isFetchingNextPage(self): + return self.page > 1 + + def run(self): + try: + projects = self.mc.paginated_projects_list( + flag=None, + only_namespace=self.namespace, + # todo: switch back to "namespace_asc,name_asc" as it currently crashes ee.dev and ce.dev + order_params="name_asc", + name=self.name, + page=self.page, + ) + if self.isInterruptionRequested(): + return + self.finished.emit(projects) + + except (URLError, ClientError) as e: + return + + +class ProjectSelectionDialog(QDialog): + + new_project_clicked = pyqtSignal() + switch_workspace_clicked = pyqtSignal() + open_project_clicked = pyqtSignal(str) + download_project_clicked = pyqtSignal(dict) + + def __init__(self, mc, workspace_name): + QDialog.__init__(self) + self.ui = uic.loadUi(ui_file, self, "Mergin") + + self.ui.label_logo.setPixmap(QPixmap(icon_path("mm_logo.svg", False))) + + self.mc = mc + self.current_workspace_name = workspace_name + + self.fetched_projects_number = 0 + self.total_projects_number = 0 + self.current_search_term = "" + self.need_to_fetch_next_page = False + self.request_page = 1 + self.text_change_timer = QTimer() + self.text_change_timer.setSingleShot(True) + self.text_change_timer.setInterval(500) + self.text_change_timer.timeout.connect(self.fetch_from_server) + self.fetcher = None + + self.model = ProjectsModel() + self.proxy = QSortFilterProxyModel() + self.proxy.setSourceModel(self.model) + self.proxy.setFilterRole(ProjectsModel.NAME) + self.proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + self.ui.project_list.setItemDelegate(ProjectItemDelegate()) + self.ui.project_list.setModel(self.proxy) + selectionModel = self.ui.project_list.selectionModel() + selectionModel.selectionChanged.connect(self.on_selection_changed) + self.ui.project_list.doubleClicked.connect(self.on_double_click) + self.ui.project_list.verticalScrollBar().valueChanged.connect(self.on_scrollbar_changed) + + self.ui.line_edit.setShowSearchIcon(True) + self.ui.line_edit.textChanged.connect(self.on_text_changed) + self.ui.line_edit.setFocus() + + self.ui.open_project_btn.setEnabled(False) + self.ui.open_project_btn.clicked.connect(self.on_open_project_clicked) + + self.ui.new_project_btn.clicked.connect(self.on_new_project_clicked) + self.ui.switch_workspace_label.linkActivated.connect(self.on_switch_workspace_clicked) + + self.text_change_timer.start() + + def fetch_from_server(self, fetch_next_page=False): + self.proxy.setFilterFixedString("") + if not fetch_next_page: + self.request_page = 1 + self.ui.project_list.clearSelection() + self.ui.project_list.scrollToTop() + self.model.clear() + self.fetched_projects_number = 0 + self.total_projects_number = 0 + + if self.fetcher and self.fetcher.isRunning(): + if fetch_next_page and self.fetcher.isFetchingNextPage(): + # We only want one fetch_next_page request at a time + return + else: + # Let's replace the existing request with the new one + self.fetcher.requestInterruption() + QgsApplication.instance().restoreOverrideCursor() + + self.current_search_term = self.ui.line_edit.text() + self.fetcher = ResultFetcher(self.mc, self.current_workspace_name, self.request_page, self.current_search_term) + self.fetcher.finished.connect(self.handle_server_response) + QgsApplication.instance().setOverrideCursor(Qt.WaitCursor) + self.fetcher.start() + + def handle_server_response(self, projects): + try: + self.fetched_projects_number += len(projects["projects"]) + self.total_projects_number = projects["count"] + if self.total_projects_number > self.fetched_projects_number: + self.request_page += 1 + self.need_to_fetch_next_page = True + else: + self.need_to_fetch_next_page = False + + self.model.appendProjects(projects["projects"]) + except KeyError: + pass + QgsApplication.instance().restoreOverrideCursor() + + def on_scrollbar_changed(self, value): + if not self.need_to_fetch_next_page: + return + + if self.ui.project_list.verticalScrollBar().maximum() <= value: + self.fetch_from_server(fetch_next_page=True) + + def on_text_changed(self, text): + if ( + self.fetcher + and not self.fetcher.isRunning() + and not self.need_to_fetch_next_page + and text.startswith(self.current_search_term) + ): + # We already have all results from server, let's filter locally + self.proxy.setFilterFixedString(text) + return + + self.text_change_timer.start() + + def on_selection_changed(self, selected, deselected): + index = self.selectedIndex() + self.ui.open_project_btn.setEnabled(index.isValid()) + + def on_open_project_clicked(self): + index = self.selectedIndex() + if not index.isValid(): + return + + project_path = self.proxy.data(index, ProjectsModel.LOCAL_DIRECTORY) + if not project_path: + project = self.proxy.data(index, ProjectsModel.PROJECT) + self.close() + self.download_project_clicked.emit(project) + return + + self.close() + self.open_project_clicked.emit(project_path) + + def on_double_click(self, index): + self.on_open_project_clicked() + + def on_new_project_clicked(self): + self.close() + self.new_project_clicked.emit() + + def on_switch_workspace_clicked(self): + self.close() + self.switch_workspace_clicked.emit() + + def enable_workspace_switching(self, enable): + self.ui.switch_workspace_label.setVisible(enable) + + def enable_new_project(self, enable): + self.ui.new_project_btn.setVisible(enable) + + def selectedIndex(self): + try: + index = self.ui.project_list.selectedIndexes()[0] + except IndexError: + index = QModelIndex() + return index + + +class PublicProjectSelectionDialog(ProjectSelectionDialog): + def __init__(self, mc): + super(PublicProjectSelectionDialog, self).__init__(mc, workspace_name="") + + self.setWindowTitle("Explore public projects") + self.ui.label.setText("Explore public community projects") + + self.ui.project_list.setItemDelegate(ProjectItemDelegate(show_namespace=True)) + self.enable_workspace_switching(False) + self.enable_new_project(False) + self.proxy.setFilterRole(ProjectsModel.NAME_WITH_NAMESPACE) diff --git a/Mergin/project_status_dialog.py b/Mergin/project_status_dialog.py index e01499ad..d0437846 100644 --- a/Mergin/project_status_dialog.py +++ b/Mergin/project_status_dialog.py @@ -87,7 +87,10 @@ def __init__( def _get_info_text(self, has_files_to_replace, has_write_permissions, has_unfinished_pull): msg = [] if not has_write_permissions: - msg.append(f"You don't have writing permissions to this project. Changes won't be synced!") + msg.append( + f"You don't have writing permissions to this project. Changes won't be synced!\n" + f"You may package the current project to a writable workspace instead, by selecting Create New Project." + ) if has_files_to_replace: msg.append( @@ -97,7 +100,7 @@ def _get_info_text(self, has_files_to_replace, has_write_permissions, has_unfini if has_unfinished_pull: msg.append( - f"The previous pull has not finished completely: status " f"of some files may be reported incorrectly." + f"The previous pull has not finished completely: status of some files may be reported incorrectly." ) return msg diff --git a/Mergin/projects_manager.py b/Mergin/projects_manager.py index b7cb6c65..49bc07e9 100644 --- a/Mergin/projects_manager.py +++ b/Mergin/projects_manager.py @@ -1,9 +1,11 @@ import os from urllib.parse import urlparse +from pathlib import Path +import posixpath -from qgis.core import QgsProject, Qgis +from qgis.core import QgsProject, Qgis, QgsApplication from qgis.utils import iface -from qgis.PyQt.QtWidgets import QMessageBox, QApplication, QPushButton +from qgis.PyQt.QtWidgets import QMessageBox, QApplication, QPushButton, QFileDialog from qgis.PyQt.QtCore import QSettings, Qt, QTimer from urllib.error import URLError @@ -363,14 +365,21 @@ def submit_logs(self, project_dir): def get_mergin_browser_groups(self): """ - Return browser tree items of Mergin Maps provider. These should be the 3 projects groups, or Error item, if + Return browser tree items of Mergin Maps provider. These should be the 2 projects groups, or Error item, if the plugin is not properly configured. """ browser_model = self.iface.browserModel() root_idx = browser_model.findPath("Mergin Maps") if not root_idx.isValid(): return {} - group_items = [browser_model.dataItem(browser_model.index(i, 0, parent=root_idx)) for i in range(3)] + group_items = [] + for i in range(browser_model.rowCount(root_idx)): + item = browser_model.dataItem(browser_model.index(i, 0, parent=root_idx)) + try: + if item.isMerginGroupItem(): + group_items.append(item) + except AttributeError as e: + pass return {i.path().replace("/Mergin", ""): i for i in group_items} def report_conflicts(self, conflicts): @@ -425,3 +434,62 @@ def close_project_and_fix_pull(self, project_dir): # we have to wait a bit to let the OS (Windows) release lock on the GPKG files # otherwise attempt to resolve unfinished pull will fail QTimer.singleShot(delay, lambda: self.resolve_unfinished_pull(project_dir, True)) + + def download_project(self, project): + project_name = posixpath.join(project["namespace"], project["name"]) # we need posix path for server API calls + settings = QSettings() + last_parent_dir = settings.value("Mergin/lastUsedDownloadDir", str(Path.home())) + parent_dir = QFileDialog.getExistingDirectory(None, "Open Directory", last_parent_dir, QFileDialog.ShowDirsOnly) + if not parent_dir: + return + settings.setValue("Mergin/lastUsedDownloadDir", parent_dir) + target_dir = os.path.abspath(os.path.join(parent_dir, project["name"])) + if os.path.exists(target_dir): + QMessageBox.warning( + None, + "Download Project", + "The target directory already exists:\n" + target_dir + "\n\nPlease select a different directory.", + ) + return + + dlg = SyncDialog() + dlg.download_start(self.mc, target_dir, project_name) + dlg.exec_() # blocks until completion / failure / cancellation + if dlg.exception: + if isinstance(dlg.exception, (URLError, ValueError)): + QgsApplication.messageLog().logMessage("Mergin Maps plugin: " + str(dlg.exception)) + msg = ( + "Failed to download your project {}.\n" + "Please make sure your Mergin Maps settings are correct".format(project_name) + ) + QMessageBox.critical(None, "Project download", msg, QMessageBox.Close) + elif isinstance(dlg.exception, LoginError): + login_error_message(dlg.exception) + else: + unhandled_exception_message( + dlg.exception_details(), + "Project download", + f"Failed to download project {project_name} due to an unhandled exception.", + ) + return + if not dlg.is_complete: + return # either it has been cancelled or an error has been thrown + + settings.setValue("Mergin/localProjects/{}/path".format(project_name), target_dir) + msg = "Your project {} has been successfully downloaded. Do you want to open project file?".format(project_name) + btn_reply = QMessageBox.question( + None, "Project download", msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes + ) + if btn_reply == QMessageBox.Yes: + self.open_project(target_dir) + + # reload the two browser groups (in case server is old) + groups = self.get_mergin_browser_groups() + for group in groups: + groups[group].reload() + + # reload the Mergin Maps browser entry (in case server is ee/ce) + browser_model = self.iface.browserModel() + root_idx = browser_model.findPath("Mergin Maps") + item = browser_model.dataItem(root_idx) + item.reload() diff --git a/Mergin/ui/ui_clone_project.ui b/Mergin/ui/ui_clone_project.ui index 26584f6f..39872cd6 100644 --- a/Mergin/ui/ui_clone_project.ui +++ b/Mergin/ui/ui_clone_project.ui @@ -18,16 +18,26 @@ - - + + - + 0 0 - + / + + + + + + + + 0 + 0 + @@ -52,7 +62,20 @@ - Owner + Workspace + + + + + + + + 0 + 0 + + + + @@ -81,31 +104,31 @@ - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - / - - - + + + + true + + + Warning message goes here + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + diff --git a/Mergin/ui/ui_project_settings_page.ui b/Mergin/ui/ui_project_settings_page.ui index 21261a07..d1523fc7 100644 --- a/Mergin/ui/ui_project_settings_page.ui +++ b/Mergin/ui/ui_project_settings_page.ui @@ -29,7 +29,7 @@ - + 200 @@ -37,7 +37,7 @@ - Owner + Workspace diff --git a/Mergin/ui/ui_select_project_dialog.ui b/Mergin/ui/ui_select_project_dialog.ui new file mode 100644 index 00000000..74ee2545 --- /dev/null +++ b/Mergin/ui/ui_select_project_dialog.ui @@ -0,0 +1,114 @@ + + + Dialog + + + + 0 + 0 + 502 + 586 + + + + Find project + + + + + + + + + Looking for a project from a different workspace? <a href="null">Click here to switch workspace</a> + + + Qt::TextBrowserInteraction + + + + + + + + 0 + 0 + + + + Create new project + + + false + + + + + + + Select a project to work with + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + Open project + + + true + + + + + + + + 256 + 76 + + + + mmLabel + + + true + + + Qt::AlignCenter + + + + + + + + + + + QgsFilterLineEdit + QLineEdit +
qgsfilterlineedit.h
+
+
+ + line_edit + project_list + open_project_btn + new_project_btn + + + +
diff --git a/Mergin/ui/ui_select_workspace_dialog.ui b/Mergin/ui/ui_select_workspace_dialog.ui new file mode 100644 index 00000000..b634fa3d --- /dev/null +++ b/Mergin/ui/ui_select_workspace_dialog.ui @@ -0,0 +1,92 @@ + + + Dialog + + + + 0 + 0 + 393 + 432 + + + + Select workspace + + + + + + + + + + + + Select a workspace to work with + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + Select workspace + + + true + + + + + + + + 256 + 76 + + + + mmLabel + + + true + + + Qt::AlignCenter + + + + + + + Want to manage your workspaces? <a href="null">Click here to go to Mergin Maps web</a> + + + Qt::TextBrowserInteraction + + + + + + + + QgsFilterLineEdit + QLineEdit +
qgsfilterlineedit.h
+
+
+ + +
diff --git a/Mergin/utils.py b/Mergin/utils.py index 7db750ef..4d7143b3 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -51,7 +51,7 @@ try: - from .mergin.client import MerginClient, ClientError, LoginError, InvalidProject + from .mergin.client import MerginClient, ClientError, LoginError, InvalidProject, ServerType from .mergin.client_pull import ( download_project_async, download_project_is_running, @@ -78,7 +78,7 @@ this_dir = os.path.dirname(os.path.realpath(__file__)) path = os.path.join(this_dir, "mergin_client.whl") sys.path.append(path) - from mergin.client import MerginClient, ClientError, InvalidProject, LoginError + from mergin.client import MerginClient, ClientError, InvalidProject, LoginError, ServerType from mergin.client_pull import ( download_project_async, download_project_is_running, @@ -1259,3 +1259,11 @@ def package_datum_grids(dest_dir): if dest_dir is not None: os.makedirs(dest_dir, exist_ok=True) copy_datum_shift_grids(dest_dir) + + +def compare_versions(first, second): + """ + Compares two version strings and returns an integer less than, equal to, + or greater than zero if first is less than, equal to, or greater than second. + """ + return int(first[1:]) - int(second[1:]) diff --git a/Mergin/workspace_selection_dialog.py b/Mergin/workspace_selection_dialog.py new file mode 100644 index 00000000..64bc0723 --- /dev/null +++ b/Mergin/workspace_selection_dialog.py @@ -0,0 +1,147 @@ +import os +from qgis.PyQt.QtWidgets import QDialog, QAbstractItemDelegate, QStyle +from qgis.PyQt.QtCore import ( + QSortFilterProxyModel, + QAbstractListModel, + Qt, + pyqtSignal, + QModelIndex, + QSize, + QRect, + QMargins, +) +from qgis.PyQt import uic +from qgis.PyQt.QtGui import QPixmap, QFontMetrics, QFont + +from .utils import ( + icon_path, +) + +ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_select_workspace_dialog.ui") + + +class WorkspacesModel(QAbstractListModel): + def __init__(self, workspaces): + super(WorkspacesModel, self).__init__() + self.workspaces = workspaces + + def rowCount(self, parent=None, *args, **kwargs): + return len(self.workspaces) + + def data(self, index, role): + workspace = self.workspaces[index.row()] + if role == Qt.UserRole: + return workspace + if role == Qt.ToolTipRole: + name = workspace["name"] + desc = workspace["description"] or "" + count = workspace["project_count"] + return "Workspace: {}\nDescription: {}\nProjects: {}".format(name, desc, count) + return workspace["name"] + + +class WorkspaceItemDelegate(QAbstractItemDelegate): + def __init__(self): + super(WorkspaceItemDelegate, self).__init__() + + def sizeHint(self, option, index): + fm = QFontMetrics(option.font) + return QSize(150, fm.height() * 3 + fm.leading()) + + def paint(self, painter, option, index): + workspace = index.data(Qt.UserRole) + description = workspace["description"] + if description: + description = description.replace("\n", " ") + nameFont = QFont(option.font) + nameFont.setWeight(QFont.Weight.Bold) + fm = QFontMetrics(nameFont) + padding = fm.lineSpacing() // 2 + + nameRect = QRect(option.rect) + nameRect.setLeft(nameRect.left() + padding) + nameRect.setTop(nameRect.top() + padding) + nameRect.setRight(nameRect.right() - 50) + nameRect.setHeight(fm.lineSpacing()) + infoRect = QRect(option.rect) + infoRect.setLeft(infoRect.left() + padding) + infoRect.setTop(infoRect.bottom() - padding - fm.lineSpacing()) + infoRect.setRight(infoRect.right() - padding) + infoRect.setHeight(fm.lineSpacing()) + borderRect = QRect(option.rect.marginsRemoved(QMargins(4, 4, 4, 4))) + + painter.save() + if option.state & QStyle.State_Selected: + painter.fillRect(borderRect, option.palette.highlight()) + painter.drawRect(borderRect) + painter.setFont(nameFont) + painter.drawText(nameRect, Qt.AlignLeading, workspace["name"]) + painter.setFont(option.font) + fm = QFontMetrics(QFont(option.font)) + elided_description = fm.elidedText(description, Qt.ElideRight, infoRect.width()) + painter.drawText(infoRect, Qt.AlignLeading, elided_description) + painter.restore() + + +class WorkspaceSelectionDialog(QDialog): + + manage_workspaces_clicked = pyqtSignal(str) + + def __init__(self, workspaces): + QDialog.__init__(self) + self.ui = uic.loadUi(ui_file, self) + + self.ui.label_logo.setPixmap(QPixmap(icon_path("mm_logo.svg", False))) + + self.workspace = None + + self.model = WorkspacesModel(workspaces) + + self.proxy = QSortFilterProxyModel() + self.proxy.setSourceModel(self.model) + self.proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + + self.ui.workspace_list.setItemDelegate(WorkspaceItemDelegate()) + self.ui.workspace_list.setModel(self.proxy) + selectionModel = self.ui.workspace_list.selectionModel() + selectionModel.selectionChanged.connect(self.on_selection_changed) + self.ui.workspace_list.doubleClicked.connect(self.on_double_click) + + self.ui.line_edit.setShowSearchIcon(True) + self.ui.line_edit.setVisible(len(workspaces) >= 5) + self.ui.line_edit.textChanged.connect(self.proxy.setFilterFixedString) + + self.ui.select_workspace_btn.setEnabled(False) + self.ui.select_workspace_btn.clicked.connect(self.on_select_workspace_clicked) + self.ui.manage_workspaces_label.linkActivated.connect(self.on_manage_workspaces_clicked) + + def on_selection_changed(self, selected, deselected): + try: + index = selected.indexes()[0] + except IndexError: + index = QModelIndex() + + self.ui.select_workspace_btn.setEnabled(index.isValid()) + + def on_select_workspace_clicked(self): + self.accept() + + def on_double_click(self, index): + self.accept() + + def on_manage_workspaces_clicked(self): + self.manage_workspaces_clicked.emit("/workspaces") + + def get_workspace(self): + return self.workspace + + def accept(self): + try: + index = self.ui.workspace_list.selectedIndexes()[0] + except IndexError: + index = QModelIndex() + if not index.isValid(): + return + + self.workspace = self.proxy.data(index, Qt.UserRole) + QDialog.accept(self)