diff --git a/Mergin/configure_sync_wizard.py b/Mergin/configure_sync_wizard.py new file mode 100644 index 00000000..fd1d0ef9 --- /dev/null +++ b/Mergin/configure_sync_wizard.py @@ -0,0 +1,239 @@ +import os + +from qgis.PyQt import uic +from qgis.PyQt.QtCore import QSettings +from qgis.PyQt.QtWidgets import QWizard, QFileDialog + +from qgis.gui import QgsFileWidget +from qgis.core import QgsProject, QgsProviderRegistry, QgsApplication, QgsAuthMethodConfig + +from .utils import get_mergin_auth + +base_dir = os.path.dirname(__file__) +ui_direction_page, base_direction_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_sync_direction_page.ui")) +ui_gpkg_select_page, base_gpkg_select_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_gpkg_selection_page.ui")) +ui_db_select_page, base_db_select_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_db_selection_page.ui")) +ui_config_page, base_config_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_config_file_page.ui")) + +SYNC_DIRECTION_PAGE = 0 +GPKG_SELECT_PAGE = 1 +DB_SELECT_PAGE = 2 +CONFIG_PAGE = 3 + + +class SyncDirectionPage(ui_direction_page, base_direction_page): + """Initial wizard page with sync direction selector.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + + self.ledit_sync_direction.hide() + self.registerField("init_from*", self.ledit_sync_direction) + + self.radio_from_project.toggled.connect(self.update_direction) + self.radio_from_db.toggled.connect(self.update_direction) + + def update_direction(self, checked): + if self.radio_from_project.isChecked(): + self.ledit_sync_direction.setText("gpkg") + else: + self.ledit_sync_direction.setText("db") + + def nextId(self): + """Decide about the next page based on selected sync direction.""" + if self.radio_from_project.isChecked(): + return GPKG_SELECT_PAGE + return DB_SELECT_PAGE + + +class GpkgSelectionPage(ui_gpkg_select_page, base_gpkg_select_page): + """Wizard page for selecting GPKG file.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + + self.ledit_gpkg_file.hide() + self.registerField("sync_file", self.ledit_gpkg_file) + + def initializePage(self): + direction = self.field("init_from") + if direction == "gpkg": + self.label.setText("Pick a GeoPackage file that contains data to be synchronized") + self.file_edit_gpkg.setStorageMode(QgsFileWidget.GetFile) + else: + self.label.setText("Pick a GeoPackage file that will contain synchronized data") + self.file_edit_gpkg.setStorageMode(QgsFileWidget.SaveFile) + + self.file_edit_gpkg.setDialogTitle(self.tr('Select file')) + settings = QSettings() + self.file_edit_gpkg.setDefaultRoot(settings.value('Mergin/lastUsedDirectory', QgsProject.instance().homePath(), str)) + self.file_edit_gpkg.setFilter('GeoPackage files (*.gpkg *.GPKG)') + self.file_edit_gpkg.fileChanged.connect(self.ledit_gpkg_file.setText) + + def nextId(self): + """Decide about the next page based on selected sync direction.""" + direction = self.field("init_from") + if direction == "gpkg": + return DB_SELECT_PAGE + return CONFIG_PAGE + + +class DatabaseSelectionPage(ui_db_select_page, base_db_select_page): + """Wizard page for selecting database and schema.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + + self.ledit_sync_schema.hide() + self.populate_connections() + + self.registerField("connection*", self.cmb_db_conn, "currentText", self.cmb_db_conn.currentTextChanged) + self.registerField("sync_schema*", self.ledit_sync_schema) + self.registerField("internal_schema*", self.line_edit_internal_schema) + self.cmb_db_conn.currentTextChanged.connect(self.populate_schemas) + + def initializePage(self): + self.direction = self.field("init_from") + if self.direction == "gpkg": + self.label_sync_schema.setText("Schema name for sync (will be created)") + # use line edit for schema name + self.stackedWidget.setCurrentIndex(0) + self.line_edit_sync_schema.textChanged.connect(self.schema_changed) + else: + self.label_sync_schema.setText("Existing schema name for sync") + # use combobox to select existing schema + self.stackedWidget.setCurrentIndex(1) + self.cmb_sync_schema.currentTextChanged.connect(self.schema_changed) + + # pre-fill internal schema name + self.line_edit_internal_schema.setText("merginmaps_db_sync") + + def cleanupPage(self): + if self.direction == "gpkg": + self.line_edit_sync_schema.textChanged.disconnect() + else: + self.cmb_sync_schema.currentTextChanged.disconnect() + + def schema_changed(self, schema_name): + self.line_edit_internal_schema.setText(f"{schema_name}_db_sync") + self.ledit_sync_schema.setText(schema_name) + + def populate_connections(self): + metadata = QgsProviderRegistry.instance().providerMetadata("postgres") + connections = metadata.dbConnections() + for k, v in connections.items(): + self.cmb_db_conn.addItem(k, v) + + self.cmb_db_conn.setCurrentIndex(-1) + + def populate_schemas(self): + connection = self.cmb_db_conn.currentData() + if connection: + self.cmb_sync_schema.clear() + self.cmb_sync_schema.addItems(connection.schemas()) + + def nextId(self): + """Decide about the next page based on selected sync direction.""" + if self.direction == "gpkg": + return CONFIG_PAGE + return GPKG_SELECT_PAGE + + +class ConfigFilePage(ui_config_page, base_config_page): + """Wizard page with generated config file.""" + + def __init__(self, project_name, parent=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + + self.project_name = project_name + + self.btn_save_config.clicked.connect(self.save_config) + + def initializePage(self): + self.text_config_file.setPlainText(self.generate_config()) + + def save_config(self): + file_path, _ = QFileDialog.getSaveFileName(self, "Save file", os.path.expanduser('~'), "YAML files (*.yml *.YML)") + if file_path: + if not file_path.lower().endswith(".yml"): + file_path += ".yml" + + with open(file_path, "w", encoding="utf-8") as f: + f.write(self.text_config_file.toPlainText()) + + def generate_config(self): + url, user, password = get_mergin_auth() + conn_name = self.field("connection") + metadata = QgsProviderRegistry.instance().providerMetadata("postgres") + conn = metadata.dbConnections()[conn_name] + decoded_uri = metadata.decodeUri(conn.uri()) + conn_string = [] + if "host" in decoded_uri: + conn_string.append(f"host={decoded_uri['host']}") + if "dbname" in decoded_uri: + conn_string.append(f"dbname={decoded_uri['dbname']}") + + if "authcfg" in decoded_uri: + auth_id = decoded_uri["authcfg"] + auth_manager = QgsApplication.authManager() + auth_config = QgsAuthMethodConfig() + auth_manager.loadAuthenticationConfig(auth_id, auth_config, True) + conn_string.append(f"user={auth_config.config('username')}") + conn_string.append(f"password={auth_config.config('password')}") + else: + if "username" in decoded_uri: + user_name = decoded_uri['username'].strip("'") + conn_string.append(f"user={user_name}") + if "password" in decoded_uri: + password = decoded_uri['password'].strip("'") + conn_string.append(f"password={password}") + + cfg = ("mergin:\n" + f" url: {url}\n" + f" username: {user}\n" + f" password: {password}\n" + f"init_from: {self.field('init_from')}\n" + "connections:\n" + f" - driver: postgres\n" + f" conn_info: \"{' '.join(conn_string)}\"\n" + f" modified: {self.field('sync_schema')}\n" + f" base: {self.field('internal_schema')}\n" + f" mergin_project: {self.project_name}\n" + f" sync_file: {self.field('sync_file')}\n" + f"daemon:\n" + f" sleep_time: 10\n" + ) + + return cfg + + +class DbSyncConfigWizard(QWizard): + """Wizard for configuring db-sync.""" + + def __init__(self, project_name, parent=None): + """Create a wizard""" + super().__init__(parent) + + self.setWindowTitle("Create db-sync configuration") + + self.project_name = project_name + + self.start_page = SyncDirectionPage(self) + self.setPage(SYNC_DIRECTION_PAGE, self.start_page) + + self.gpkg_page = GpkgSelectionPage(parent=self) + self.setPage(GPKG_SELECT_PAGE, self.gpkg_page) + + self.db_page = DatabaseSelectionPage(parent=self) + self.setPage(DB_SELECT_PAGE, self.db_page) + + self.config_page = ConfigFilePage(self.project_name, parent=self) + self.setPage(CONFIG_PAGE, self.config_page) diff --git a/Mergin/images/default/tabler_icons/database-cog.svg b/Mergin/images/default/tabler_icons/database-cog.svg new file mode 100644 index 00000000..47f2f65f --- /dev/null +++ b/Mergin/images/default/tabler_icons/database-cog.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Mergin/images/white/tabler_icons/database-cog.svg b/Mergin/images/white/tabler_icons/database-cog.svg new file mode 100644 index 00000000..444ab41a --- /dev/null +++ b/Mergin/images/white/tabler_icons/database-cog.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 676cbd78..f4280d26 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -39,6 +39,7 @@ from .project_settings_widget import MerginProjectConfigFactory from .projects_manager import MerginProjectsManager from .sync_dialog import SyncDialog +from .configure_sync_wizard import DbSyncConfigWizard from .utils import ( ServerType, ClientError, @@ -143,6 +144,13 @@ def initGui(self): enabled=False, always_on=False, ) + self.action_db_sync_wizard = self.add_action( + "database-cog.svg", + text="Configure DB sync", + callback=self.configure_db_sync, + add_to_menu=True, + add_to_toolbar=None, + ) self.enable_toolbar_actions() @@ -278,6 +286,28 @@ def configure(self): self.on_config_changed() self.show_browser_panel() + def configure_db_sync(self): + """Open db-sync setup wizard.""" + project_path = QgsProject.instance().homePath() + if not project_path: + iface.messageBar().pushMessage("Mergin", "Project is not saved, please save project first", Qgis.Warning) + return + + if not check_mergin_subdirs(project_path): + iface.messageBar().pushMessage("Mergin", "Current project is not a Mergin project. Please open a Mergin project first.", Qgis.Warning) + return + + mp = MerginProject(project_path) + try: + project_name = mp.metadata["name"] + except InvalidProject as e: + iface.messageBar().pushMessage("Mergin", "Current project is not a Mergin project. Please open a Mergin project first.", Qgis.Warning) + return + + wizard = DbSyncConfigWizard(project_name) + if not wizard.exec_(): + return + def show_no_workspaces_dialog(self): msg = ( "Workspace is a place to store your projects and share them with your colleagues. " diff --git a/Mergin/ui/ui_config_file_page.ui b/Mergin/ui/ui_config_file_page.ui new file mode 100644 index 00000000..9a5bd6cf --- /dev/null +++ b/Mergin/ui/ui_config_file_page.ui @@ -0,0 +1,95 @@ + + + WizardPage + + + + 0 + 0 + 400 + 269 + + + + Configuration file + + + + + + Configuration file + + + + + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Save + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + See documentation on how to run the sync tool with generated configuration file + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/Mergin/ui/ui_db_selection_page.ui b/Mergin/ui/ui_db_selection_page.ui new file mode 100644 index 00000000..c25d5386 --- /dev/null +++ b/Mergin/ui/ui_db_selection_page.ui @@ -0,0 +1,121 @@ + + + WizardPage + + + + 0 + 0 + 498 + 253 + + + + Select DB connection + + + + + + Pick a PostgreSQL database connection + + + + + + + + + + Existing schema name for sync + + + + + + + 0 + + + + + 0 + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + Schema name for internal use (will be created) + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 500 + + + + + + + + + diff --git a/Mergin/ui/ui_gpkg_selection_page.ui b/Mergin/ui/ui_gpkg_selection_page.ui new file mode 100644 index 00000000..0ecd9e4b --- /dev/null +++ b/Mergin/ui/ui_gpkg_selection_page.ui @@ -0,0 +1,45 @@ + + + WizardPage + + + + 0 + 0 + 400 + 78 + + + + Select GeoPackage + + + + + + Pick a GeoPackage file that contains data to be synchronized + + + true + + + + + + + + + + + + + + QgsFileWidget + QWidget +
qgis.gui
+ 1 +
+
+ + +
diff --git a/Mergin/ui/ui_sync_direction_page.ui b/Mergin/ui/ui_sync_direction_page.ui new file mode 100644 index 00000000..c1f71df3 --- /dev/null +++ b/Mergin/ui/ui_sync_direction_page.ui @@ -0,0 +1,61 @@ + + + WizardPage + + + + 0 + 0 + 400 + 174 + + + + WizardPage + + + + + + <html><head/><body><p>This wizard will create a configuration file for the Mergin Maps database sync tool for the currently opened project.</p><p>Please start by picking how the sync will be initialized</p></body></html> + + + true + + + + + + + Initialize from Mergin Maps project + + + + + + + Initialize from database + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + +