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
+
+
+
+
+ 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
+
+
+
+
+
+
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)