├── .github └── workflows │ ├── code_style.yml │ ├── packages.yml │ └── run-test.yml ├── .gitignore ├── LICENSE.txt ├── Mergin ├── __init__.py ├── attachment_fields_model.py ├── clone_project_dialog.py ├── collapsible_message_box.py ├── configuration_dialog.py ├── configure_sync_wizard.py ├── create_project_wizard.py ├── diff.py ├── diff_dialog.py ├── help.py ├── images │ ├── MM_symbol_COLOR_TRANSPARENT.png │ ├── default │ │ ├── MM_logo_HORIZ_COLOR_VECTOR.svg │ │ ├── MM_symbol_COLOR_no_padding.svg │ │ └── tabler_icons │ │ │ ├── LICENSE │ │ │ ├── alert-triangle.svg │ │ │ ├── cloud-download.svg │ │ │ ├── cloud.svg │ │ │ ├── copy.svg │ │ │ ├── database-cog.svg │ │ │ ├── dots.svg │ │ │ ├── explore.svg │ │ │ ├── file-description.svg │ │ │ ├── file-diff.svg │ │ │ ├── file-export.svg │ │ │ ├── file-plus.svg │ │ │ ├── first-aid-kit.svg │ │ │ ├── folder-plus.svg │ │ │ ├── history.svg │ │ │ ├── list.svg │ │ │ ├── pencil.svg │ │ │ ├── plus.svg │ │ │ ├── refresh.svg │ │ │ ├── repeat.svg │ │ │ ├── replace.svg │ │ │ ├── revert-changes.svg │ │ │ ├── search.svg │ │ │ ├── settings.svg │ │ │ ├── square-plus.svg │ │ │ ├── table.svg │ │ │ ├── trash.svg │ │ │ ├── user.svg │ │ │ └── users.svg │ └── white │ │ ├── MM_logo_HORIZ_COLOR_INVERSE_VECTOR.svg │ │ ├── MM_symbol_COLOR_INVERSE_no_padding.svg │ │ ├── MM_symbol_COLOR_INVERSE_small_padding.svg │ │ └── tabler_icons │ │ ├── LICENSE │ │ ├── alert-triangle.svg │ │ ├── cloud-download.svg │ │ ├── cloud.svg │ │ ├── copy.svg │ │ ├── database-cog.svg │ │ ├── dots.svg │ │ ├── file-description.svg │ │ ├── file-diff.svg │ │ ├── file-export.svg │ │ ├── file-plus.svg │ │ ├── first-aid-kit.svg │ │ ├── folder-plus.svg │ │ ├── history.svg │ │ ├── list.svg │ │ ├── pencil.svg │ │ ├── plus.svg │ │ ├── refresh.svg │ │ ├── repeat.svg │ │ ├── revert-changes.svg │ │ ├── settings.svg │ │ ├── square-plus.svg │ │ ├── table.svg │ │ ├── trash.svg │ │ ├── user.svg │ │ └── users.svg ├── metadata.txt ├── plugin.py ├── processing │ ├── algs │ │ ├── create_diff.py │ │ ├── create_report.py │ │ ├── download_vector_tiles.py │ │ └── extract_local_changes.py │ ├── postprocessors.py │ └── provider.py ├── project_selection_dialog.py ├── project_settings_widget.py ├── project_status_dialog.py ├── projects_manager.py ├── remove_project_dialog.py ├── repair.py ├── sync_dialog.py ├── test │ ├── __init__.py │ ├── data │ │ ├── dem.prj │ │ ├── dem.qpj │ │ ├── dem.tfw │ │ ├── dem.tif │ │ ├── dem.tifw │ │ ├── dem.wld │ │ ├── raster-tiles.mbtiles │ │ ├── schema_base.json │ │ ├── schema_two_tables.json │ │ ├── transport_aerodrome.svg │ │ └── vector-tiles.mbtiles │ ├── suite.py │ ├── test_help.py │ ├── test_packaging.py │ ├── test_utils.py │ └── test_validations.py ├── ui │ ├── ui_clone_project.ui │ ├── ui_config.ui │ ├── ui_config_file_page.ui │ ├── ui_create_project.ui │ ├── ui_db_selection_page.ui │ ├── ui_diff_viewer_dialog.ui │ ├── ui_get_path_page.ui │ ├── ui_gpkg_selection_page.ui │ ├── ui_new_proj_init_page.ui │ ├── ui_packaging_page.ui │ ├── ui_project_config.ui │ ├── ui_project_history_dock.ui │ ├── ui_project_settings_page.ui │ ├── ui_remove_project_dialog.ui │ ├── ui_select_project_dialog.ui │ ├── ui_select_workspace_dialog.ui │ ├── ui_status_dialog.ui │ ├── ui_sync_dialog.ui │ ├── ui_sync_direction_page.ui │ └── ui_versions_viewer.ui ├── utils.py ├── validation.py ├── version_viewer_dialog.py └── workspace_selection_dialog.py ├── README.md ├── docs └── dev-docs.md └── scripts ├── check_all.bash ├── deploy_win.py ├── install_dev_linux.sh ├── update_version.bash └── update_version.py /.github/workflows/code_style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: [push] 4 | 5 | jobs: 6 | code_style_python: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable 11 | with: 12 | options: "--check --diff --verbose -l 120" 13 | src: "./Mergin" -------------------------------------------------------------------------------- /.github/workflows/packages.yml: -------------------------------------------------------------------------------- 1 | name: Build Mergin Plugin Packages 2 | on: 3 | push: 4 | workflow_dispatch: 5 | inputs: 6 | PYTHON_API_CLIENT_VER: 7 | description: 'python-api-client version: either a tag, release, or a branch' 8 | required: true 9 | default: 'master' 10 | type: string 11 | GEODIFF_VER: 12 | description: 'Geodiff version released on PyPI repository' 13 | default: '2.0.4' 14 | type: string 15 | env: 16 | # Assign the version provided by 'workflow_dispatch' if available; otherwise, use the default. 17 | PYTHON_API_CLIENT_VER: ${{ inputs.PYTHON_API_CLIENT_VER != '' && inputs.PYTHON_API_CLIENT_VER || '0.10.0' }} 18 | GEODIFF_VER: ${{ inputs.GEODIFF_VER != '' && inputs.GEODIFF_VER || '2.0.4' }} 19 | PYTHON_VER: "38" 20 | PLUGIN_NAME: Mergin 21 | jobs: 22 | build_linux_binary: 23 | name: Extract geodiff binary linux 24 | runs-on: ubuntu-latest 25 | env: 26 | PY_PLATFORM: "manylinux2014_x86_64" 27 | steps: 28 | - uses: actions/setup-python@v4 29 | name: Install Python 30 | 31 | - name: Download pygeodiff binaries 32 | run: | 33 | pip3 download --only-binary=:all: \ 34 | --python-version ${PYTHON_VER} \ 35 | --no-deps --platform ${PY_PLATFORM} \ 36 | --implementation cp \ 37 | --abi cp${PYTHON_VER} pygeodiff==${GEODIFF_VER} 38 | unzip -o pygeodiff-$GEODIFF_VER-cp${PYTHON_VER}-cp${PYTHON_VER}-manylinux_2_17_x86_64.${PY_PLATFORM}.whl -d tmp || true 39 | mkdir pygeodiff-binaries 40 | cp tmp/pygeodiff/libpygeodiff-${GEODIFF_VER}-python.so ./pygeodiff-binaries/ 41 | 42 | - name: Patching pygeodiff binaries 43 | run: | 44 | # get exact name of the linked library (e.g. libsqlite3-d9e27dab.so.0.8.6) 45 | SQLITE_LINE=$(ldd ./pygeodiff-binaries/libpygeodiff-${GEODIFF_VER}-python.so | grep libsqlite3) 46 | SQLITE_LIB=$(echo ${SQLITE_LINE} | sed -E "s/.*(libsqlite3-[a-z0-9]+.so[\\.0-9]+).*/\\1/") 47 | patchelf --replace-needed ${SQLITE_LIB} libsqlite3.so.0 ./pygeodiff-binaries/libpygeodiff-${GEODIFF_VER}-python.so 48 | patchelf --remove-rpath ./pygeodiff-binaries/libpygeodiff-${GEODIFF_VER}-python.so 49 | 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: artifact-pygeodiff-linux 53 | path: ./pygeodiff-binaries/*.so 54 | 55 | build_windows_binaries: 56 | name: Extract geodiff binary windows 57 | runs-on: windows-latest 58 | steps: 59 | - uses: actions/setup-python@v4 60 | name: Install Python 61 | 62 | - name: Install deps 63 | run: | 64 | choco install unzip 65 | 66 | - name: Download pygeodiff 32 binaries 67 | run: | 68 | pip3 download --only-binary=:all: --no-deps --platform "win32" --python-version $env:PYTHON_VER pygeodiff==$env:GEODIFF_VER 69 | unzip -o pygeodiff-$env:GEODIFF_VER-cp$env:PYTHON_VER-cp$env:PYTHON_VER-win32.whl -d tmp32 70 | mkdir pygeodiff-binaries 71 | copy tmp32\pygeodiff\*.pyd pygeodiff-binaries\ 72 | 73 | - name: Download pygeodiff 64 binaries 74 | run: | 75 | pip3 download --only-binary=:all: --no-deps --platform "win_amd64" --python-version $env:PYTHON_VER pygeodiff==$env:GEODIFF_VER 76 | unzip -o pygeodiff-$env:GEODIFF_VER-cp$env:PYTHON_VER-cp$env:PYTHON_VER-win_amd64.whl -d tmp64 77 | copy tmp64\pygeodiff\*.pyd pygeodiff-binaries\ 78 | 79 | - uses: actions/upload-artifact@v4 80 | with: 81 | name: artifact-pygeodiff-windows 82 | path: ./pygeodiff-binaries/*.pyd 83 | 84 | build_macos_binary: 85 | name: Extract geodiff binary macos 86 | runs-on: macos-latest 87 | env: 88 | PY_PLATFORM: "macosx_10_9_x86_64" 89 | steps: 90 | - uses: actions/setup-python@v4 91 | name: Install Python 92 | 93 | - name: Install deps 94 | run: | 95 | brew install unzip 96 | 97 | - name: Download pygeodiff binaries 98 | run: | 99 | pip3 download --only-binary=:all: --no-deps --platform ${PY_PLATFORM} --python-version ${PYTHON_VER} --implementation cp --abi cp${PYTHON_VER} pygeodiff==$GEODIFF_VER 100 | unzip -o pygeodiff-$GEODIFF_VER-cp${PYTHON_VER}-cp${PYTHON_VER}-${PY_PLATFORM}.whl -d tmp 101 | mkdir pygeodiff-binaries 102 | cp tmp/pygeodiff/*.dylib ./pygeodiff-binaries/ 103 | 104 | - name: Patching pygeodiff binaries 105 | run: | 106 | install_name_tool -change @loader_path/.dylibs/libsqlite3.0.dylib @rpath/libsqlite3.dylib ./pygeodiff-binaries/libpygeodiff-$GEODIFF_VER-python.dylib 107 | OTOOL_L=$(otool -L ./pygeodiff-binaries/libpygeodiff-$GEODIFF_VER-python.dylib) 108 | if echo "${OTOOL_L}" | grep -q loader_path 109 | then 110 | echo "libpygeodiff-$GEODIFF_VER-python.dylib was not patched correctly, maybe sqlite version changed??" 111 | exit 1 112 | fi 113 | 114 | - uses: actions/upload-artifact@v4 115 | with: 116 | name: artifact-pygeodiff-macos 117 | path: ./pygeodiff-binaries/*.dylib 118 | 119 | create_mergin_plugin_package: 120 | needs: [build_windows_binaries, build_linux_binary, build_macos_binary] 121 | runs-on: ubuntu-latest 122 | steps: 123 | - uses: actions/checkout@v3 124 | with: 125 | repository: MerginMaps/python-api-client 126 | ref: ${{ env.PYTHON_API_CLIENT_VER }} 127 | path: python-api-client 128 | 129 | - name: prepare py-client dependencies 130 | run: | 131 | cd python-api-client 132 | python3 setup.py sdist bdist_wheel 133 | mkdir -p mergin/deps 134 | # without __init__.py the deps dir may get recognized as "namespace package" in python 135 | # and it can break qgis plugin unloading mechanism - see #126 136 | touch mergin/deps/__init__.py 137 | pip3 wheel -r mergin_client.egg-info/requires.txt -w mergin/deps 138 | # special care for pygeodiff 139 | unzip mergin/deps/pygeodiff-*.whl -d mergin/deps 140 | # remove unncesessary files 141 | rm -rf mergin/deps/*.dist-info 142 | rm -rf mergin/deps/*.data 143 | rm -rf mergin/deps/pygeodiff.libs 144 | rm -rf mergin/deps/pygeodiff-*.whl 145 | 146 | - name: check geodiff version in sync with python-api-client 147 | run: | 148 | GEODIFF_VER_FROM_CLIENT="$(geodiff="$(cat python-api-client/mergin_client.egg-info/requires.txt | grep pygeodiff)";echo ${geodiff#pygeodiff==})" 149 | if [ "$GEODIFF_VER" != "$GEODIFF_VER_FROM_CLIENT" ]; then 150 | echo "geodiff version defined in python-api-client requires.txt $GEODIFF_VER_FROM_CLIENT does not equal $GEODIFF_VER from the workpackage file" 151 | exit 1; # or just warning?? 152 | fi 153 | - name: Merge Artifacts 154 | uses: actions/upload-artifact/merge@v4 155 | with: 156 | name: artifact 157 | path: artifact-pygeodiff 158 | pattern: artifact-pygeodiff-* 159 | merge-multiple: true 160 | delete-merged: true 161 | 162 | - uses: actions/download-artifact@v4 163 | with: 164 | name: artifact 165 | path: pygeodiff-binaries 166 | 167 | - name: include pygeodiff deps 168 | run: | 169 | cp pygeodiff-binaries/* python-api-client/mergin/deps/pygeodiff 170 | 171 | - uses: actions/checkout@v3 172 | with: 173 | path: qgis-mergin-plugin 174 | 175 | - name: create package 176 | run: | 177 | cp -r python-api-client/mergin qgis-mergin-plugin/Mergin 178 | rsync -av --exclude='test' --exclude='/__pycache__/' --exclude='*/__pycache__/' qgis-mergin-plugin/Mergin output 179 | # from 1 June 2024, plugins are required to include LICENSE file 180 | cp qgis-mergin-plugin/LICENSE.txt output/Mergin/LICENSE 181 | (cd output && zip -r9 ../mergin.zip Mergin/) 182 | 183 | 184 | 185 | - uses: actions/upload-artifact@v4 186 | with: 187 | name: Mergin 188 | path: output/ 189 | 190 | - name: upload asset on tagged release 191 | uses: softprops/action-gh-release@v1 192 | if: startsWith(github.ref, 'refs/tags/') 193 | with: 194 | files: mergin.zip 195 | env: 196 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 197 | -------------------------------------------------------------------------------- /.github/workflows/run-test.yml: -------------------------------------------------------------------------------- 1 | name: Run Mergin Plugin Tests 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | inputs: 7 | PYTHON_API_CLIENT_VER: 8 | description: 'python-api-client version: either a tag, release, or a branch' 9 | required: true 10 | default: 'master' 11 | type: string 12 | 13 | env: 14 | # Assign the version provided by 'workflow_dispatch' if available; otherwise, use the default. 15 | PYTHON_API_CLIENT_VER: ${{ inputs.PYTHON_API_CLIENT_VER != '' && inputs.PYTHON_API_CLIENT_VER || 'master' }} 16 | PLUGIN_NAME: Mergin 17 | TEST_FUNCTION: suite.test_all 18 | DOCKER_IMAGE: qgis/qgis 19 | 20 | concurrency: 21 | group: ci-${{github.ref}}-autotests 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | run-tests: 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | matrix: 30 | docker_tags: [release-3_22, release-3_34] 31 | 32 | steps: 33 | 34 | - name: Checkout client code 35 | uses: actions/checkout@v3 36 | with: 37 | repository: MerginMaps/python-api-client 38 | ref: ${{ env.PYTHON_API_CLIENT_VER }} 39 | path: client 40 | 41 | - name: Install python-api-client dependencies 42 | run: | 43 | pip3 install python-dateutil pytz wheel 44 | cd client 45 | mkdir -p mergin/deps 46 | pip3 install pygeodiff --target=mergin/deps 47 | python3 setup.py sdist bdist_wheel 48 | # without __init__.py the deps dir may get recognized as "namespace package" in python 49 | # and it can break qgis plugin unloading mechanism - see #126 50 | touch mergin/deps/__init__.py 51 | pip3 wheel -r mergin_client.egg-info/requires.txt -w mergin/deps 52 | unzip -o mergin/deps/pygeodiff-*.whl -d mergin/deps 53 | 54 | - name: Checkout plugin code 55 | uses: actions/checkout@v3 56 | with: 57 | path: plugin 58 | 59 | - name: Copy client files to the plugin directory 60 | run: | 61 | cp -r client/mergin plugin/Mergin 62 | 63 | - name: Docker pull and create qgis-testing-environment 64 | run: | 65 | docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} 66 | docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE"/plugin:/tests_directory -e DISPLAY=:99 "$DOCKER_IMAGE":${{ matrix.docker_tags }} 67 | # Wait for xvfb to finish starting 68 | printf "Waiting for the docker...🐳..." 69 | sleep 10 70 | echo " done 🥩" 71 | 72 | - name: Docker set up QGIS 73 | run: | 74 | docker exec qgis-testing-environment sh -c "qgis_setup.sh $PLUGIN_NAME" 75 | docker exec qgis-testing-environment sh -c "rm -f /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" 76 | docker exec qgis-testing-environment sh -c "ln -s /tests_directory/$PLUGIN_NAME /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" 77 | 78 | - name: Docker run plugin tests 79 | run: | 80 | docker exec qgis-testing-environment sh -c "cd /tests_directory/$PLUGIN_NAME/test && qgis_testrunner.sh $TEST_FUNCTION" 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build-* 2 | Makefile 3 | Makefile.* 4 | *.aux.xml 5 | *.orig 6 | *~ 7 | nbproject* 8 | *.zip 9 | *-swp 10 | .idea/ 11 | .idea/* 12 | *.vscode 13 | *.autosave 14 | .pytest_cache 15 | .cache 16 | .coverage 17 | *.pyc 18 | __pycache__/ 19 | Mergin/mergin 20 | *.whl 21 | .idea/ 22 | .DS_Store 23 | mergin-py-client 24 | -------------------------------------------------------------------------------- /Mergin/__init__.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | 5 | def classFactory(iface): 6 | from .plugin import MerginPlugin 7 | 8 | return MerginPlugin(iface) 9 | -------------------------------------------------------------------------------- /Mergin/attachment_fields_model.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | from qgis.PyQt.QtGui import QStandardItemModel, QStandardItem 5 | from qgis.PyQt.QtCore import Qt, QSize 6 | from qgis.core import QgsProject, QgsMapLayer, QgsSymbolLayerUtils 7 | 8 | # QgsIconUtils only available since QGIS >= 3.20 9 | try: 10 | from qgis.core import QgsIconUtils 11 | 12 | has_icon_utils = True 13 | except ImportError: 14 | has_icon_utils = False 15 | 16 | 17 | class AttachmentFieldsModel(QStandardItemModel): 18 | LAYER_ID = Qt.ItemDataRole.UserRole + 1 19 | FIELD_NAME = Qt.ItemDataRole.UserRole + 2 20 | EXPRESSION = Qt.ItemDataRole.UserRole + 3 21 | 22 | def __init__(self, parent=None): 23 | super().__init__(parent) 24 | 25 | self.setHorizontalHeaderLabels(["Layer", "Field"]) 26 | 27 | parent_item = self.invisibleRootItem() 28 | 29 | layers = QgsProject.instance().mapLayers() 30 | for layer_id, layer in layers.items(): 31 | if layer.type() != QgsMapLayer.VectorLayer: 32 | continue 33 | 34 | for field in layer.fields(): 35 | widget_setup = field.editorWidgetSetup() 36 | if widget_setup.type() != "ExternalResource": 37 | continue 38 | 39 | item_layer = QStandardItem(f"{layer.name()}") 40 | renderer = layer.renderer() 41 | if renderer and renderer.type() == "singleSymbol": 42 | icon = QgsSymbolLayerUtils.symbolPreviewIcon(renderer.symbol(), QSize(16, 16)) 43 | item_layer.setIcon(icon) 44 | elif has_icon_utils: 45 | item_layer.setIcon(QgsIconUtils.iconForLayer(layer)) 46 | 47 | item_field = QStandardItem(f"{field.name()}") 48 | item_field.setData(layer_id, AttachmentFieldsModel.LAYER_ID) 49 | item_field.setData(field.name(), AttachmentFieldsModel.FIELD_NAME) 50 | exp, ok = QgsProject.instance().readEntry("Mergin", f"PhotoNaming/{layer_id}/{field.name()}") 51 | item_field.setData(exp, AttachmentFieldsModel.EXPRESSION) 52 | parent_item.appendRow([item_layer, item_field]) 53 | -------------------------------------------------------------------------------- /Mergin/clone_project_dialog.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | 6 | from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QApplication, QMessageBox, QComboBox 7 | from qgis.PyQt.QtCore import Qt 8 | from qgis.PyQt import uic 9 | 10 | from .utils import is_valid_name 11 | 12 | ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_clone_project.ui") 13 | 14 | 15 | class CloneProjectDialog(QDialog): 16 | """Dialog for cloning remote projects. Allows selection of workspace/namespace and project name""" 17 | 18 | def __init__(self, user_info, default_workspace=None): 19 | """Create a dialog for cloning remote projects 20 | 21 | :param user_info: The user_info dictionary as returned from server 22 | :param default_workspace: Optionally, the name of the current workspace so it can be pre-selected in the list 23 | """ 24 | QDialog.__init__(self) 25 | self.ui = uic.loadUi(ui_file, self) 26 | self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) 27 | self.ui.buttonBox.accepted.connect(self.accept_dialog) 28 | 29 | workspaces = user_info.get("workspaces", None) 30 | if workspaces is not None: 31 | for ws in workspaces: 32 | is_writable = ws.get("role", "owner") in ["owner", "admin", "writer"] 33 | self.ui.projectNamespace.addItem(ws["name"], is_writable) 34 | 35 | else: 36 | # This means server is old and uses namespaces 37 | self.ui.projectNamespaceLabel.setText("Owner") 38 | username = user_info["username"] 39 | user_organisations = user_info.get("organisations", []) 40 | self.ui.projectNamespace.addItem(username, True) 41 | for o in user_organisations: 42 | if user_organisations[o] in ["owner", "admin", "writer"]: 43 | self.ui.projectNamespace.addItem(o, True) 44 | 45 | self.ui.projectNamespace.currentTextChanged.connect(self.validate_input) 46 | self.ui.edit_project_name.textChanged.connect(self.validate_input) 47 | 48 | # disable widgets if default workspace is read only 49 | self.validate_input() 50 | self.ui.projectNamespace.setCurrentText(default_workspace) 51 | 52 | # these are the variables used by the caller 53 | self.project_name = None 54 | self.project_namespace = None 55 | self.invalid = False 56 | 57 | def validate_input(self): 58 | is_writable = bool(self.ui.projectNamespace.currentData(Qt.ItemDataRole.UserRole)) 59 | if not is_writable: 60 | msg = "You do not have permissions to create a project in this workspace!" 61 | self.ui.edit_project_name.setEnabled(False) 62 | else: 63 | msg = "" 64 | self.ui.edit_project_name.setEnabled(True) 65 | 66 | proj_name = self.ui.edit_project_name.text() 67 | if not proj_name: 68 | msg = "Project name missing!" 69 | elif not is_valid_name(proj_name): 70 | msg = "Incorrect project name!" 71 | 72 | self.ui.edit_project_name.setToolTip(msg) 73 | self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setToolTip(msg) 74 | has_error = bool(msg) 75 | self.ui.warningMessageLabel.setVisible(has_error) 76 | self.ui.warningMessageLabel.setText(msg) 77 | self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not has_error) 78 | 79 | def accept_dialog(self): 80 | self.project_name = self.ui.edit_project_name.text() 81 | self.project_namespace = self.ui.projectNamespace.currentText() 82 | 83 | self.accept() # this will close the dialog and dlg.exec() returns True 84 | -------------------------------------------------------------------------------- /Mergin/collapsible_message_box.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | from qgis.PyQt.QtCore import Qt 5 | from qgis.PyQt.QtWidgets import QMessageBox 6 | 7 | 8 | class CollapsibleBox(QtWidgets.QWidget): 9 | def __init__(self, text, details, title="Mergin Maps error", parent=None): 10 | msg = QMessageBox() 11 | msg.setWindowTitle(title) 12 | msg.setTextFormat(Qt.TextFormat.RichText) 13 | msg.setText(text) 14 | msg.setIcon(QMessageBox.Icon.Warning) 15 | msg.setStandardButtons(QMessageBox.StandardButton.Close) 16 | msg.setDefaultButton(QMessageBox.StandardButton.Close) 17 | msg.setDetailedText(details) 18 | msg.exec() 19 | -------------------------------------------------------------------------------- /Mergin/configuration_dialog.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | from qgis.PyQt.QtWidgets import QDialog, QApplication, QDialogButtonBox, QMessageBox 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import Qt, QSettings 8 | from qgis.PyQt.QtGui import QPixmap 9 | from qgis.core import QgsApplication, QgsExpressionContextUtils 10 | from urllib.error import URLError 11 | 12 | try: 13 | from .mergin.client import MerginClient, ClientError, LoginError 14 | except ImportError: 15 | import sys 16 | 17 | this_dir = os.path.dirname(os.path.realpath(__file__)) 18 | path = os.path.join(this_dir, "mergin_client.whl") 19 | sys.path.append(path) 20 | from mergin.client import MerginClient, ClientError, LoginError 21 | 22 | from .utils import ( 23 | get_mergin_auth, 24 | set_mergin_auth, 25 | MERGIN_URL, 26 | create_mergin_client, 27 | get_plugin_version, 28 | get_qgis_proxy_config, 29 | test_server_connection, 30 | mm_logo_path, 31 | is_dark_theme, 32 | ) 33 | 34 | ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_config.ui") 35 | 36 | 37 | class ConfigurationDialog(QDialog): 38 | def __init__(self): 39 | QDialog.__init__(self) 40 | self.ui = uic.loadUi(ui_file, self) 41 | settings = QSettings() 42 | if is_dark_theme(): 43 | self.ui.label.setText( 44 | "Don't have an account yet? Sign up now!" 45 | ) 46 | else: 47 | self.ui.label.setText( 48 | "Don't have an account yet? Sign up now!" 49 | ) 50 | 51 | save_credentials = settings.value("Mergin/saveCredentials", "false").lower() == "true" 52 | if save_credentials: 53 | QgsApplication.authManager().setMasterPassword() 54 | url, username, password = get_mergin_auth() 55 | self.ui.label_logo.setPixmap(QPixmap(mm_logo_path())) 56 | self.ui.merginURL.setText(url) 57 | self.ui.username.setText(username) 58 | self.ui.password.setText(password) 59 | self.ui.save_credentials.setChecked(save_credentials) 60 | self.ui.test_connection_btn.clicked.connect(self.test_connection) 61 | self.ui.test_status.setText("") 62 | self.ui.master_password_status.setText("") 63 | self.ui.custom_url.setChecked(url.rstrip("/") != MERGIN_URL) 64 | self.ui.merginURL.setVisible(self.ui.custom_url.isChecked()) 65 | self.ui.custom_url.stateChanged.connect(self.toggle_custom_url) 66 | self.ui.save_credentials.stateChanged.connect(self.check_master_password) 67 | self.ui.username.textChanged.connect(self.check_credentials) 68 | self.ui.password.textChanged.connect(self.check_credentials) 69 | self.check_credentials() 70 | 71 | def accept(self): 72 | if not self.test_connection(): 73 | return 74 | 75 | super().accept() 76 | 77 | def toggle_custom_url(self): 78 | self.ui.merginURL.setVisible(self.ui.custom_url.isChecked()) 79 | 80 | def server_url(self): 81 | return self.ui.merginURL.text() if self.ui.custom_url.isChecked() else MERGIN_URL 82 | 83 | def check_credentials(self): 84 | credentials_are_set = bool(self.ui.username.text()) and bool(self.ui.password.text()) 85 | self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(credentials_are_set) 86 | self.ui.test_connection_btn.setEnabled(credentials_are_set) 87 | 88 | def check_master_password(self): 89 | if not self.ui.save_credentials.isChecked(): 90 | self.ui.master_password_status.setText("") 91 | return 92 | 93 | if QgsApplication.authManager().masterPasswordIsSet(): 94 | self.ui.master_password_status.setText("") 95 | else: 96 | self.ui.master_password_status.setText( 97 | " Warning: You may be prompted for QGIS master password " 98 | ) 99 | 100 | def writeSettings(self): 101 | url = self.server_url() 102 | login_name = self.ui.username.text() 103 | login_password = self.ui.password.text() 104 | settings = QSettings() 105 | settings.setValue("Mergin/auth_token", None) # reset token 106 | settings.setValue("Mergin/saveCredentials", str(self.ui.save_credentials.isChecked())) 107 | 108 | if self.ui.save_credentials.isChecked(): 109 | set_mergin_auth(url, login_name, login_password) 110 | try: 111 | mc = create_mergin_client() 112 | except (URLError, ClientError, LoginError): 113 | mc = None 114 | else: 115 | try: 116 | proxy_config = get_qgis_proxy_config(url) 117 | mc = MerginClient(url, None, login_name, login_password, get_plugin_version(), proxy_config) 118 | settings.setValue("Mergin/auth_token", mc._auth_session["token"]) 119 | settings.setValue("Mergin/server", url) 120 | except (URLError, ClientError, LoginError) as e: 121 | QgsApplication.messageLog().logMessage(f"Mergin Maps plugin: {str(e)}") 122 | mc = None 123 | 124 | QgsExpressionContextUtils.setGlobalVariable("mergin_url", url) 125 | QgsExpressionContextUtils.setGlobalVariable("mm_url", url) 126 | if mc: 127 | # username can be username or email, so we fetch it from api 128 | user_info = mc.user_info() 129 | username = user_info["username"] 130 | user_email = user_info["email"] 131 | user_full_name = user_info["name"] 132 | settings.setValue("Mergin/username", username) 133 | settings.setValue("Mergin/user_email", user_email) 134 | settings.setValue("Mergin/full_name", user_full_name) 135 | QgsExpressionContextUtils.setGlobalVariable("mergin_username", username) 136 | QgsExpressionContextUtils.setGlobalVariable("mergin_user_email", user_email) 137 | QgsExpressionContextUtils.setGlobalVariable("mergin_full_name", user_full_name) 138 | QgsExpressionContextUtils.setGlobalVariable("mm_username", username) 139 | QgsExpressionContextUtils.setGlobalVariable("mm_user_email", user_email) 140 | QgsExpressionContextUtils.setGlobalVariable("mm_full_name", user_full_name) 141 | else: 142 | QgsExpressionContextUtils.removeGlobalVariable("mergin_username") 143 | QgsExpressionContextUtils.removeGlobalVariable("mergin_user_email") 144 | QgsExpressionContextUtils.removeGlobalVariable("mergin_full_name") 145 | QgsExpressionContextUtils.removeGlobalVariable("mm_username") 146 | QgsExpressionContextUtils.removeGlobalVariable("mm_user_email") 147 | QgsExpressionContextUtils.removeGlobalVariable("mm_full_name") 148 | 149 | return mc 150 | 151 | def test_connection(self): 152 | QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) 153 | ok, msg = test_server_connection(self.server_url(), self.ui.username.text(), self.ui.password.text()) 154 | QApplication.restoreOverrideCursor() 155 | self.ui.test_status.setText(msg) 156 | return ok 157 | -------------------------------------------------------------------------------- /Mergin/configure_sync_wizard.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import QSettings 8 | from qgis.PyQt.QtWidgets import QWizard, QFileDialog 9 | 10 | from qgis.gui import QgsFileWidget 11 | from qgis.core import QgsProject, QgsProviderRegistry, QgsApplication, QgsAuthMethodConfig 12 | 13 | from .utils import get_mergin_auth 14 | 15 | base_dir = os.path.dirname(__file__) 16 | ui_direction_page, base_direction_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_sync_direction_page.ui")) 17 | ui_gpkg_select_page, base_gpkg_select_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_gpkg_selection_page.ui")) 18 | ui_db_select_page, base_db_select_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_db_selection_page.ui")) 19 | ui_config_page, base_config_page = uic.loadUiType(os.path.join(base_dir, "ui", "ui_config_file_page.ui")) 20 | 21 | SYNC_DIRECTION_PAGE = 0 22 | GPKG_SELECT_PAGE = 1 23 | DB_SELECT_PAGE = 2 24 | CONFIG_PAGE = 3 25 | 26 | 27 | class SyncDirectionPage(ui_direction_page, base_direction_page): 28 | """Initial wizard page with sync direction selector.""" 29 | 30 | def __init__(self, parent=None): 31 | super().__init__(parent) 32 | self.setupUi(self) 33 | self.parent = parent 34 | 35 | self.ledit_sync_direction.hide() 36 | self.registerField("init_from*", self.ledit_sync_direction) 37 | 38 | self.radio_from_project.toggled.connect(self.update_direction) 39 | self.radio_from_db.toggled.connect(self.update_direction) 40 | 41 | def update_direction(self, checked): 42 | if self.radio_from_project.isChecked(): 43 | self.ledit_sync_direction.setText("gpkg") 44 | else: 45 | self.ledit_sync_direction.setText("db") 46 | 47 | def nextId(self): 48 | """Decide about the next page based on selected sync direction.""" 49 | if self.radio_from_project.isChecked(): 50 | return GPKG_SELECT_PAGE 51 | return DB_SELECT_PAGE 52 | 53 | 54 | class GpkgSelectionPage(ui_gpkg_select_page, base_gpkg_select_page): 55 | """Wizard page for selecting GPKG file.""" 56 | 57 | def __init__(self, parent=None): 58 | super().__init__(parent) 59 | self.setupUi(self) 60 | self.parent = parent 61 | 62 | self.ledit_gpkg_file.hide() 63 | self.registerField("sync_file", self.ledit_gpkg_file) 64 | 65 | def initializePage(self): 66 | direction = self.field("init_from") 67 | if direction == "gpkg": 68 | self.label.setText("Pick a GeoPackage file that contains data to be synchronised") 69 | self.file_edit_gpkg.setStorageMode(QgsFileWidget.GetFile) 70 | else: 71 | self.label.setText("Pick a GeoPackage file that will contain synchronised data") 72 | self.file_edit_gpkg.setStorageMode(QgsFileWidget.SaveFile) 73 | 74 | self.file_edit_gpkg.setDialogTitle(self.tr("Select file")) 75 | settings = QSettings() 76 | self.file_edit_gpkg.setDefaultRoot( 77 | settings.value("Mergin/lastUsedDirectory", QgsProject.instance().homePath(), str) 78 | ) 79 | self.file_edit_gpkg.setFilter("GeoPackage files (*.gpkg *.GPKG)") 80 | self.file_edit_gpkg.fileChanged.connect(self.ledit_gpkg_file.setText) 81 | 82 | def nextId(self): 83 | """Decide about the next page based on selected sync direction.""" 84 | direction = self.field("init_from") 85 | if direction == "gpkg": 86 | return DB_SELECT_PAGE 87 | return CONFIG_PAGE 88 | 89 | 90 | class DatabaseSelectionPage(ui_db_select_page, base_db_select_page): 91 | """Wizard page for selecting database and schema.""" 92 | 93 | def __init__(self, parent=None): 94 | super().__init__(parent) 95 | self.setupUi(self) 96 | self.parent = parent 97 | 98 | self.ledit_sync_schema.hide() 99 | self.populate_connections() 100 | 101 | self.registerField("connection*", self.cmb_db_conn, "currentText", self.cmb_db_conn.currentTextChanged) 102 | self.registerField("sync_schema*", self.ledit_sync_schema) 103 | self.registerField("internal_schema*", self.line_edit_internal_schema) 104 | self.cmb_db_conn.currentTextChanged.connect(self.populate_schemas) 105 | 106 | def initializePage(self): 107 | self.direction = self.field("init_from") 108 | if self.direction == "gpkg": 109 | self.label_sync_schema.setText("Schema name for sync (will be created)") 110 | # use line edit for schema name 111 | self.stackedWidget.setCurrentIndex(0) 112 | self.line_edit_sync_schema.textChanged.connect(self.schema_changed) 113 | else: 114 | self.label_sync_schema.setText("Existing schema name for sync") 115 | # use combobox to select existing schema 116 | self.stackedWidget.setCurrentIndex(1) 117 | self.cmb_sync_schema.currentTextChanged.connect(self.schema_changed) 118 | 119 | # pre-fill internal schema name 120 | self.line_edit_internal_schema.setText("merginmaps_db_sync") 121 | 122 | def cleanupPage(self): 123 | if self.direction == "gpkg": 124 | self.line_edit_sync_schema.textChanged.disconnect() 125 | else: 126 | self.cmb_sync_schema.currentTextChanged.disconnect() 127 | 128 | def schema_changed(self, schema_name): 129 | self.line_edit_internal_schema.setText(f"{schema_name}_db_sync") 130 | self.ledit_sync_schema.setText(schema_name) 131 | 132 | def populate_connections(self): 133 | metadata = QgsProviderRegistry.instance().providerMetadata("postgres") 134 | connections = metadata.dbConnections() 135 | for k, v in connections.items(): 136 | self.cmb_db_conn.addItem(k, v) 137 | 138 | self.cmb_db_conn.setCurrentIndex(-1) 139 | 140 | def populate_schemas(self): 141 | connection = self.cmb_db_conn.currentData() 142 | if connection: 143 | self.cmb_sync_schema.clear() 144 | self.cmb_sync_schema.addItems(connection.schemas()) 145 | 146 | def nextId(self): 147 | """Decide about the next page based on selected sync direction.""" 148 | if self.direction == "gpkg": 149 | return CONFIG_PAGE 150 | return GPKG_SELECT_PAGE 151 | 152 | 153 | class ConfigFilePage(ui_config_page, base_config_page): 154 | """Wizard page with generated config file.""" 155 | 156 | def __init__(self, project_name, parent=None): 157 | super().__init__(parent) 158 | self.setupUi(self) 159 | self.parent = parent 160 | 161 | self.project_name = project_name 162 | 163 | self.btn_save_config.clicked.connect(self.save_config) 164 | 165 | def initializePage(self): 166 | self.text_config_file.setPlainText(self.generate_config()) 167 | 168 | def save_config(self): 169 | file_path, _ = QFileDialog.getSaveFileName( 170 | self, "Save file", os.path.expanduser("~"), "YAML files (*.yml *.YML)" 171 | ) 172 | if file_path: 173 | if not file_path.lower().endswith(".yml"): 174 | file_path += ".yml" 175 | 176 | with open(file_path, "w", encoding="utf-8") as f: 177 | f.write(self.text_config_file.toPlainText()) 178 | 179 | def generate_config(self): 180 | url, user, password = get_mergin_auth() 181 | metadata = QgsProviderRegistry.instance().providerMetadata("postgres") 182 | conn = metadata.dbConnections()[self.field("connection")] 183 | decoded_uri = metadata.decodeUri(conn.uri()) 184 | conn_string = [] 185 | if "host" in decoded_uri: 186 | conn_string.append(f"host={decoded_uri['host']}") 187 | if "dbname" in decoded_uri: 188 | conn_string.append(f"dbname={decoded_uri['dbname']}") 189 | 190 | if "authcfg" in decoded_uri: 191 | auth_id = decoded_uri["authcfg"] 192 | auth_manager = QgsApplication.authManager() 193 | auth_config = QgsAuthMethodConfig() 194 | auth_manager.loadAuthenticationConfig(auth_id, auth_config, True) 195 | conn_string.append(f"user={auth_config.config('username')}") 196 | conn_string.append(f"password={auth_config.config('password')}") 197 | else: 198 | if "username" in decoded_uri: 199 | user_name = decoded_uri["username"].strip("'") 200 | conn_string.append(f"user={user_name}") 201 | if "password" in decoded_uri: 202 | password = decoded_uri["password"].strip("'") 203 | conn_string.append(f"password={password}") 204 | 205 | cfg = ( 206 | "mergin:\n" 207 | f" url: {url}\n" 208 | f" username: {user}\n" 209 | f" password: {password}\n" 210 | f"init_from: {self.field('init_from')}\n" 211 | "connections:\n" 212 | f" - driver: postgres\n" 213 | f" conn_info: \"{' '.join(conn_string)}\"\n" 214 | f" modified: {self.field('sync_schema')}\n" 215 | f" base: {self.field('internal_schema')}\n" 216 | f" mergin_project: {self.project_name}\n" 217 | f" sync_file: {os.path.split(self.field('sync_file'))[1]}\n" 218 | f"daemon:\n" 219 | f" sleep_time: 10\n" 220 | ) 221 | 222 | return cfg 223 | 224 | 225 | class DbSyncConfigWizard(QWizard): 226 | """Wizard for configuring db-sync.""" 227 | 228 | def __init__(self, project_name, parent=None): 229 | """Create a wizard""" 230 | super().__init__(parent) 231 | 232 | self.setWindowTitle("Create db-sync configuration") 233 | 234 | self.project_name = project_name 235 | 236 | self.start_page = SyncDirectionPage(self) 237 | self.setPage(SYNC_DIRECTION_PAGE, self.start_page) 238 | 239 | self.gpkg_page = GpkgSelectionPage(parent=self) 240 | self.setPage(GPKG_SELECT_PAGE, self.gpkg_page) 241 | 242 | self.db_page = DatabaseSelectionPage(parent=self) 243 | self.setPage(DB_SELECT_PAGE, self.db_page) 244 | 245 | self.config_page = ConfigFilePage(self.project_name, parent=self) 246 | self.setPage(CONFIG_PAGE, self.config_page) 247 | -------------------------------------------------------------------------------- /Mergin/diff_dialog.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import Qt, QSettings 8 | from qgis.PyQt.QtGui import QIcon, QColor 9 | from qgis.PyQt.QtWidgets import QDialog, QPushButton, QDialogButtonBox, QMenu, QAction 10 | from qgis.core import ( 11 | QgsProject, 12 | QgsVectorLayerCache, 13 | QgsFeatureRequest, 14 | QgsMapLayer, 15 | QgsMessageLog, 16 | Qgis, 17 | QgsApplication, 18 | QgsWkbTypes, 19 | ) 20 | from qgis.gui import QgsGui, QgsMapToolPan, QgsAttributeTableModel, QgsAttributeTableFilterModel 21 | from qgis.utils import iface, OverrideCursor 22 | 23 | from .mergin.merginproject import MerginProject 24 | from .diff import make_local_changes_layer 25 | from .utils import icon_path, icon_for_layer 26 | 27 | ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_diff_viewer_dialog.ui") 28 | 29 | 30 | class DiffViewerDialog(QDialog): 31 | def __init__(self, parent=None): 32 | QDialog.__init__(self, parent) 33 | self.ui = uic.loadUi(ui_file, self) 34 | 35 | with OverrideCursor(Qt.CursorShape.WaitCursor): 36 | QgsGui.instance().enableAutoGeometryRestore(self) 37 | settings = QSettings() 38 | state = settings.value("Mergin/changesViewerSplitterSize") 39 | if state: 40 | self.splitter.restoreState(state) 41 | else: 42 | height = max( 43 | [self.map_canvas.minimumSizeHint().height(), self.attribute_table.minimumSizeHint().height()] 44 | ) 45 | self.splitter.setSizes([height, height]) 46 | 47 | self.toggle_layers_action = QAction( 48 | QgsApplication.getThemeIcon("/mActionAddLayer.svg"), "Toggle Project Layers", self 49 | ) 50 | self.toggle_layers_action.setCheckable(True) 51 | self.toggle_layers_action.setChecked(True) 52 | self.toggle_layers_action.toggled.connect(self.toggle_background_layers) 53 | self.toolbar.addAction(self.toggle_layers_action) 54 | 55 | self.toolbar.addSeparator() 56 | 57 | self.zoom_full_action = QAction( 58 | QgsApplication.getThemeIcon("/mActionZoomFullExtent.svg"), "Zoom Full", self 59 | ) 60 | self.zoom_full_action.triggered.connect(self.zoom_full) 61 | self.toolbar.addAction(self.zoom_full_action) 62 | 63 | self.zoom_selected_action = QAction( 64 | QgsApplication.getThemeIcon("/mActionZoomToSelected.svg"), "Zoom To Selection", self 65 | ) 66 | self.zoom_selected_action.triggered.connect(self.zoom_selected) 67 | self.toolbar.addAction(self.zoom_selected_action) 68 | 69 | self.toolbar.addSeparator() 70 | 71 | btn_add_changes = QPushButton("Add to project") 72 | btn_add_changes.setIcon(QgsApplication.getThemeIcon("/mActionAdd.svg")) 73 | menu = QMenu() 74 | add_current_action = menu.addAction( 75 | QIcon(icon_path("file-plus.svg")), "Add current changes layer to project" 76 | ) 77 | add_current_action.triggered.connect(self.add_current_to_project) 78 | add_all_action = menu.addAction(QIcon(icon_path("folder-plus.svg")), "Add all changes layers to project") 79 | add_all_action.triggered.connect(self.add_all_to_project) 80 | btn_add_changes.setMenu(menu) 81 | 82 | self.toolbar.addWidget(btn_add_changes) 83 | self.toolbar.setIconSize(iface.iconSize()) 84 | 85 | self.map_canvas.enableAntiAliasing(True) 86 | self.map_canvas.setSelectionColor(QColor(Qt.GlobalColor.cyan)) 87 | self.pan_tool = QgsMapToolPan(self.map_canvas) 88 | self.map_canvas.setMapTool(self.pan_tool) 89 | 90 | self.tab_bar.setUsesScrollButtons(True) 91 | self.tab_bar.currentChanged.connect(self.diff_layer_changed) 92 | 93 | self.current_diff = None 94 | self.diff_layers = [] 95 | self.filter_model = None 96 | 97 | self.create_tabs() 98 | 99 | def reject(self): 100 | self.save_splitter_state() 101 | QDialog.reject(self) 102 | 103 | def closeEvent(self, event): 104 | self.save_splitter_state() 105 | QDialog.closeEvent(self, event) 106 | 107 | def save_splitter_state(self): 108 | settings = QSettings() 109 | settings.setValue("Mergin/changesViewerSplitterSize", self.splitter.saveState()) 110 | 111 | def create_tabs(self): 112 | mp = MerginProject(QgsProject.instance().homePath()) 113 | project_layers = QgsProject.instance().mapLayers() 114 | for layer in project_layers.values(): 115 | if layer.type() != QgsMapLayer.VectorLayer: 116 | continue 117 | 118 | if layer.dataProvider().storageType() != "GPKG": 119 | QgsMessageLog.logMessage(f"Layer {layer.name()} is not supported.", "Mergin") 120 | continue 121 | 122 | vl, msg = make_local_changes_layer(mp, layer) 123 | if vl is None: 124 | QgsMessageLog.logMessage(msg, "Mergin") 125 | continue 126 | 127 | self.diff_layers.append(vl) 128 | self.tab_bar.addTab(icon_for_layer(vl), f"{layer.name()} ({vl.featureCount()})") 129 | self.tab_bar.setCurrentIndex(0) 130 | 131 | def toggle_background_layers(self, checked): 132 | layers = self.collect_layers(checked) 133 | self.update_canvas(layers) 134 | 135 | def update_canvas(self, layers): 136 | self.map_canvas.setLayers(layers) 137 | if layers: 138 | self.map_canvas.setDestinationCrs(layers[0].crs()) 139 | extent = layers[0].extent() 140 | d = min(extent.width(), extent.height()) 141 | if d == 0: 142 | d = 1 143 | extent = extent.buffered(d * 0.07) 144 | self.map_canvas.setExtent(extent) 145 | self.map_canvas.refresh() 146 | 147 | def collect_layers(self, include_background_layers: bool): 148 | if include_background_layers: 149 | layers = iface.mapCanvas().layers() 150 | else: 151 | layers = [] 152 | 153 | if self.current_diff: 154 | layers.insert(0, self.current_diff) 155 | 156 | return layers 157 | 158 | def diff_layer_changed(self, index): 159 | if index > len(self.diff_layers): 160 | return 161 | 162 | self.map_canvas.setLayers([]) 163 | self.attribute_table.clearSelection() 164 | 165 | self.current_diff = self.diff_layers[index] 166 | 167 | self.layer_cache = QgsVectorLayerCache(self.current_diff, 1000) 168 | self.layer_cache.setCacheGeometry(False) 169 | 170 | self.table_model = QgsAttributeTableModel(self.layer_cache) 171 | self.table_model.setRequest(QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry).setLimit(100)) 172 | 173 | self.filter_model = QgsAttributeTableFilterModel(self.map_canvas, self.table_model) 174 | 175 | self.layer_cache.setParent(self.table_model) 176 | 177 | self.attribute_table.setModel(self.filter_model) 178 | self.table_model.loadLayer() 179 | 180 | config = self.current_diff.attributeTableConfig() 181 | self.filter_model.setAttributeTableConfig(config) 182 | self.attribute_table.setAttributeTableConfig(config) 183 | 184 | layers = self.collect_layers(self.toggle_layers_action.isChecked()) 185 | self.update_canvas(layers) 186 | 187 | def add_current_to_project(self): 188 | if self.current_diff: 189 | QgsProject.instance().addMapLayer(self.current_diff) 190 | 191 | def add_all_to_project(self): 192 | for layer in self.diff_layers: 193 | QgsProject.instance().addMapLayer(layer) 194 | 195 | def zoom_full(self): 196 | if self.current_diff: 197 | self.map_canvas.setExtent(self.current_diff.extent()) 198 | self.map_canvas.refresh() 199 | 200 | def zoom_selected(self): 201 | if self.current_diff: 202 | self.map_canvas.zoomToSelected([self.current_diff]) 203 | self.map_canvas.refresh() 204 | 205 | def show_unsaved_changes_warning(self): 206 | self.ui.messageBar.pushMessage( 207 | "Mergin", 208 | "Project contains unsaved modifications, which won't be visible in the local changes view.", 209 | Qgis.Warning, 210 | ) 211 | -------------------------------------------------------------------------------- /Mergin/help.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | HELP_ROOT = "https://merginmaps.com/docs" 5 | 6 | 7 | class MerginHelp: 8 | """Class for generating Mergin plugin help URLs.""" 9 | 10 | def howto_attachment_widget(self): 11 | return f"{HELP_ROOT}/layer/settingup_forms/" 12 | 13 | def howto_background_maps(self): 14 | return f"{HELP_ROOT}/gis/settingup_background_map/" 15 | -------------------------------------------------------------------------------- /Mergin/images/MM_symbol_COLOR_TRANSPARENT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MerginMaps/qgis-plugin/4613a7d54bb3e4189da5e193e247857232e26104/Mergin/images/MM_symbol_COLOR_TRANSPARENT.png -------------------------------------------------------------------------------- /Mergin/images/default/MM_logo_HORIZ_COLOR_VECTOR.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Mergin/images/default/MM_symbol_COLOR_no_padding.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/LICENSE: -------------------------------------------------------------------------------- 1 | All icons are licence under MIT License 2 | https://github.com/tabler/tabler-icons/blob/master/LICENSE 3 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/alert-triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/alert-triangle 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/cloud-download.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/cloud-download 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/cloud.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/cloud 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/copy 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/database-cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/dots 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/explore.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/file-description.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/file-diff.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/file-diff 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/file-export.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/file-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/file-plus 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/first-aid-kit.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/first-aid-kit 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/folder-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/folder-plus 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 40 | 45 | 48 | 51 | 52 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/list.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/list 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/pencil 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/plus 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/refresh 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/repeat.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/repeat 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/replace.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/revert-changes.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/settings 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/square-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/square-plus 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/table.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/table 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/trash 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/user 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mergin/images/default/tabler_icons/users.svg: -------------------------------------------------------------------------------- 1 | 2 | Download more icon variants from https://tabler-icons.io/i/users 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Mergin/images/white/MM_logo_HORIZ_COLOR_INVERSE_VECTOR.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Mergin/images/white/MM_symbol_COLOR_INVERSE_no_padding.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Mergin/images/white/MM_symbol_COLOR_INVERSE_small_padding.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/LICENSE: -------------------------------------------------------------------------------- 1 | All icons are licence under MIT License 2 | https://github.com/tabler/tabler-icons/blob/master/LICENSE 3 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/alert-triangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/cloud-download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/cloud.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/database-cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 40 | 46 | 50 | 54 | 58 | 62 | 66 | 70 | 74 | 78 | 82 | 86 | 90 | 91 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/dots.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/file-description.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/file-diff.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/file-export.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 40 | 45 | 48 | 52 | 53 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/file-plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/first-aid-kit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/folder-plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 40 | 45 | 48 | 51 | 52 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/list.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/pencil.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/repeat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/revert-changes.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/square-plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/table.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/images/white/tabler_icons/users.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Mergin/processing/algs/create_diff.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | import sqlite3 9 | import shutil 10 | 11 | from qgis.PyQt.QtGui import QIcon 12 | from qgis.core import ( 13 | QgsFeatureSink, 14 | QgsProcessing, 15 | QgsProcessingUtils, 16 | QgsProcessingException, 17 | QgsProcessingAlgorithm, 18 | QgsProcessingContext, 19 | QgsProcessingParameterFile, 20 | QgsProcessingParameterNumber, 21 | QgsProcessingParameterVectorLayer, 22 | QgsProcessingParameterFeatureSink, 23 | ) 24 | 25 | from ..postprocessors import StylingPostProcessor 26 | 27 | from ...mergin.merginproject import MerginProject 28 | from ...mergin.utils import get_versions_with_file_changes 29 | from ...mergin.deps import pygeodiff 30 | 31 | from ...diff import parse_db_schema, parse_diff, get_table_name, create_field_list, diff_table_to_features 32 | 33 | from ...utils import ( 34 | mm_symbol_path, 35 | create_mergin_client, 36 | check_mergin_subdirs, 37 | ) 38 | 39 | 40 | class CreateDiff(QgsProcessingAlgorithm): 41 | PROJECT_DIR = "PROJECT_DIR" 42 | LAYER = "LAYER" 43 | START_VERSION = "START_VERSION" 44 | END_VERSION = "END_VERSION" 45 | OUTPUT = "OUTPUT" 46 | 47 | def name(self): 48 | return "creatediff" 49 | 50 | def displayName(self): 51 | return "Create diff" 52 | 53 | def group(self): 54 | return "Tools" 55 | 56 | def groupId(self): 57 | return "tools" 58 | 59 | def tags(self): 60 | return "mergin,added,dropped,new,deleted,features,geometries,difference,delta,revised,original,version,compare".split( 61 | "," 62 | ) 63 | 64 | def shortHelpString(self): 65 | return "Extracts changes made between two versions of the layer of the Mergin Maps project to make it easier to revise changes." 66 | 67 | def icon(self): 68 | return QIcon(mm_symbol_path()) 69 | 70 | def __init__(self): 71 | super().__init__() 72 | 73 | def createInstance(self): 74 | return type(self)() 75 | 76 | def initAlgorithm(self, config=None): 77 | self.addParameter( 78 | QgsProcessingParameterFile(self.PROJECT_DIR, "Project directory", QgsProcessingParameterFile.Folder) 79 | ) 80 | self.addParameter(QgsProcessingParameterVectorLayer(self.LAYER, "Input layer")) 81 | self.addParameter( 82 | QgsProcessingParameterNumber( 83 | self.START_VERSION, "Start version", QgsProcessingParameterNumber.Integer, 1, False, 1 84 | ) 85 | ) 86 | self.addParameter( 87 | QgsProcessingParameterNumber( 88 | self.END_VERSION, "End version", QgsProcessingParameterNumber.Integer, None, True, 1 89 | ) 90 | ) 91 | self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Diff layer")) 92 | 93 | def processAlgorithm(self, parameters, context, feedback): 94 | project_dir = self.parameterAsString(parameters, self.PROJECT_DIR, context) 95 | layer = self.parameterAsVectorLayer(parameters, self.LAYER, context) 96 | 97 | if not check_mergin_subdirs(project_dir): 98 | raise QgsProcessingException("Selected directory does not contain a valid Mergin project.") 99 | 100 | if not os.path.normpath(layer.source()).lower().startswith(os.path.normpath(project_dir).lower()): 101 | raise QgsProcessingException("Selected layer does not belong to the selected Mergin project.") 102 | 103 | if layer.dataProvider().storageType() != "GPKG": 104 | raise QgsProcessingException("Selected layer not supported.") 105 | 106 | start = self.parameterAsInt(parameters, self.START_VERSION, context) 107 | if self.END_VERSION in parameters and parameters[self.END_VERSION] is not None: 108 | end = self.parameterAsInt(parameters, self.END_VERSION, context) 109 | else: 110 | end = "" 111 | 112 | table_name = get_table_name(layer) 113 | layer_path = layer.source().split("|")[0] 114 | file_name = os.path.split(layer_path)[1] 115 | 116 | mc = create_mergin_client() 117 | mp = MerginProject(project_dir) 118 | 119 | feedback.pushInfo("Downloading base file…") 120 | base_file = QgsProcessingUtils.generateTempFilename(file_name) 121 | mc.download_file(project_dir, file_name, base_file, f"v{end}" if end else None) 122 | feedback.setProgress(10) 123 | 124 | diff_file = QgsProcessingUtils.generateTempFilename(file_name + ".diff") 125 | try: 126 | mc.get_file_diff(project_dir, file_name, diff_file, f"v{start}", f"v{end}" if end else None) 127 | except KeyError: 128 | # see https://github.com/MerginMaps/python-api-client/issues/196 129 | raise QgsProcessingException( 130 | "The layer for given version range contains a version with changed data schema." 131 | ) 132 | 133 | feedback.setProgress(20) 134 | 135 | feedback.pushInfo("Parse schema…") 136 | db_schema = parse_db_schema(base_file) 137 | feedback.setProgress(25) 138 | 139 | feedback.pushInfo("Create field list…") 140 | fields, fields_mapping = create_field_list(db_schema[table_name]) 141 | (sink, dest_id) = self.parameterAsSink( 142 | parameters, self.OUTPUT, context, fields, layer.wkbType(), layer.sourceCrs() 143 | ) 144 | 145 | feedback.pushInfo("Parse diff…") 146 | geodiff = pygeodiff.GeoDiff() 147 | diff = parse_diff(geodiff, diff_file) 148 | feedback.setProgress(30) 149 | 150 | if diff and table_name in diff.keys(): 151 | db_conn = None # no ref. db 152 | db_conn = sqlite3.connect(layer_path) 153 | features = diff_table_to_features(diff[table_name], db_schema[table_name], fields, fields_mapping, db_conn) 154 | feedback.setProgress(40) 155 | 156 | current = 40 157 | step = 60.0 / len(features) if features else 0 158 | for i, f in enumerate(features): 159 | if feedback.isCanceled(): 160 | break 161 | sink.addFeature(f, QgsFeatureSink.FastInsert) 162 | feedback.setProgress(int(i * step)) 163 | 164 | if context.willLoadLayerOnCompletion(dest_id): 165 | context.layerToLoadOnCompletionDetails(dest_id).setPostProcessor( 166 | StylingPostProcessor.create(db_schema[table_name]) 167 | ) 168 | 169 | return {self.OUTPUT: dest_id} 170 | -------------------------------------------------------------------------------- /Mergin/processing/algs/create_report.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from qgis.PyQt.QtGui import QIcon 4 | from qgis.core import ( 5 | QgsVectorFileWriter, 6 | QgsProcessing, 7 | QgsProcessingException, 8 | QgsProcessingAlgorithm, 9 | QgsProcessingContext, 10 | QgsProcessingParameterFile, 11 | QgsProcessingParameterNumber, 12 | QgsProcessingParameterFileDestination, 13 | ) 14 | 15 | from ...utils import mm_symbol_path, create_mergin_client, create_report, ClientError, InvalidProject 16 | 17 | 18 | class CreateReport(QgsProcessingAlgorithm): 19 | PROJECT_DIR = "PROJECT_DIR" 20 | START_VERSION = "START_VERSION" 21 | END_VERSION = "END_VERSION" 22 | REPORT = "REPORT" 23 | 24 | def name(self): 25 | return "createreport" 26 | 27 | def displayName(self): 28 | return "Create report" 29 | 30 | def group(self): 31 | return "Tools" 32 | 33 | def groupId(self): 34 | return "tools" 35 | 36 | def tags(self): 37 | return "mergin,project,report,statistics".split(",") 38 | 39 | def shortHelpString(self): 40 | return "Exports changesets aggregates for Mergin Maps projects in given version range to a CSV file." 41 | 42 | def icon(self): 43 | return QIcon(mm_symbol_path()) 44 | 45 | def __init__(self): 46 | super().__init__() 47 | 48 | def createInstance(self): 49 | return type(self)() 50 | 51 | def initAlgorithm(self, config=None): 52 | self.addParameter( 53 | QgsProcessingParameterFile(self.PROJECT_DIR, "Project directory", QgsProcessingParameterFile.Folder) 54 | ) 55 | self.addParameter( 56 | QgsProcessingParameterNumber( 57 | self.START_VERSION, "Start version", QgsProcessingParameterNumber.Integer, 1, False, 1 58 | ) 59 | ) 60 | self.addParameter( 61 | QgsProcessingParameterNumber( 62 | self.END_VERSION, "End version", QgsProcessingParameterNumber.Integer, None, True, 1 63 | ) 64 | ) 65 | self.addParameter(QgsProcessingParameterFileDestination(self.REPORT, "Report", "CSV files (*.csv *.CSV)")) 66 | 67 | def processAlgorithm(self, parameters, context, feedback): 68 | project_dir = self.parameterAsString(parameters, self.PROJECT_DIR, context) 69 | start = self.parameterAsInt(parameters, self.START_VERSION, context) 70 | if self.END_VERSION in parameters and parameters[self.END_VERSION] is not None: 71 | end = self.parameterAsInt(parameters, self.END_VERSION, context) 72 | else: 73 | end = "" 74 | output_file = self.parameterAsFileOutput(parameters, self.REPORT, context) 75 | 76 | mc = create_mergin_client() 77 | warnings = None 78 | try: 79 | warnings = create_report(mc, project_dir, f"v{start}", f"v{end}" if end else "", output_file) 80 | except InvalidProject as e: 81 | raise QgsProcessingException("Invalid Mergin Maps project: " + str(e)) 82 | except ClientError as e: 83 | raise QgsProcessingException("Unable to create report: " + str(e)) 84 | 85 | if warnings: 86 | for w in warnings: 87 | feedback.pushWarning(w) 88 | 89 | context.addLayerToLoadOnCompletion(output_file, QgsProcessingContext.LayerDetails("Report", context.project())) 90 | 91 | return {self.REPORT: output_file} 92 | -------------------------------------------------------------------------------- /Mergin/processing/algs/extract_local_changes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | import sqlite3 9 | 10 | from qgis.PyQt.QtGui import QIcon 11 | from qgis.core import ( 12 | QgsFeatureSink, 13 | QgsProcessing, 14 | QgsProcessingException, 15 | QgsProcessingAlgorithm, 16 | QgsProcessingContext, 17 | QgsProcessingParameterFile, 18 | QgsProcessingParameterVectorLayer, 19 | QgsProcessingParameterFeatureSink, 20 | ) 21 | 22 | from ..postprocessors import StylingPostProcessor 23 | 24 | from ...mergin.merginproject import MerginProject 25 | from ...mergin.deps import pygeodiff 26 | from ...diff import ( 27 | get_local_changes, 28 | parse_db_schema, 29 | parse_diff, 30 | get_table_name, 31 | create_field_list, 32 | diff_table_to_features, 33 | ) 34 | 35 | from ...utils import ( 36 | mm_symbol_path, 37 | check_mergin_subdirs, 38 | ) 39 | 40 | 41 | class ExtractLocalChanges(QgsProcessingAlgorithm): 42 | PROJECT_DIR = "PROJECT_DIR" 43 | LAYER = "LAYER" 44 | OUTPUT = "OUTPUT" 45 | 46 | def name(self): 47 | return "extractlocalchanges" 48 | 49 | def displayName(self): 50 | return "Extract local changes" 51 | 52 | def group(self): 53 | return "Tools" 54 | 55 | def groupId(self): 56 | return "tools" 57 | 58 | def tags(self): 59 | return "mergin,added,dropped,new,deleted,features,geometries,difference,delta,revised,original,version,compare".split( 60 | "," 61 | ) 62 | 63 | def shortHelpString(self): 64 | return "Extracts local changes made in the specific layer of the Mergin Maps project to make it easier to revise changes." 65 | 66 | def icon(self): 67 | return QIcon(mm_symbol_path()) 68 | 69 | def __init__(self): 70 | super().__init__() 71 | 72 | def createInstance(self): 73 | return type(self)() 74 | 75 | def initAlgorithm(self, config=None): 76 | self.addParameter( 77 | QgsProcessingParameterFile(self.PROJECT_DIR, "Project directory", QgsProcessingParameterFile.Folder) 78 | ) 79 | self.addParameter(QgsProcessingParameterVectorLayer(self.LAYER, "Input layer")) 80 | self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Local changes layer")) 81 | 82 | def processAlgorithm(self, parameters, context, feedback): 83 | project_dir = self.parameterAsString(parameters, self.PROJECT_DIR, context) 84 | layer = self.parameterAsVectorLayer(parameters, self.LAYER, context) 85 | 86 | if not check_mergin_subdirs(project_dir): 87 | raise QgsProcessingException("Selected directory does not contain a valid Mergin project.") 88 | 89 | if not os.path.normpath(layer.source()).lower().startswith(os.path.normpath(project_dir).lower()): 90 | raise QgsProcessingException("Selected layer does not belong to the selected Mergin project.") 91 | 92 | if layer.dataProvider().storageType() != "GPKG": 93 | raise QgsProcessingException("Selected layer not supported.") 94 | 95 | mp = MerginProject(project_dir) 96 | 97 | geodiff = pygeodiff.GeoDiff() 98 | 99 | layer_path = layer.source().split("|")[0] 100 | diff_path = get_local_changes(geodiff, layer_path, mp) 101 | feedback.setProgress(5) 102 | 103 | if diff_path is None: 104 | raise QgsProcessingException("Failed to get local changes.") 105 | 106 | table_name = get_table_name(layer) 107 | 108 | db_schema = parse_db_schema(layer_path) 109 | feedback.setProgress(10) 110 | 111 | fields, fields_mapping = create_field_list(db_schema[table_name]) 112 | (sink, dest_id) = self.parameterAsSink( 113 | parameters, self.OUTPUT, context, fields, layer.wkbType(), layer.sourceCrs() 114 | ) 115 | 116 | diff = parse_diff(geodiff, diff_path) 117 | feedback.setProgress(15) 118 | 119 | if diff and table_name in diff.keys(): 120 | db_conn = None # no ref. db 121 | db_conn = sqlite3.connect(layer_path) 122 | features = diff_table_to_features(diff[table_name], db_schema[table_name], fields, fields_mapping, db_conn) 123 | feedback.setProgress(20) 124 | 125 | current = 20 126 | step = 80.0 / len(features) if features else 0 127 | for i, f in enumerate(features): 128 | if feedback.isCanceled(): 129 | break 130 | sink.addFeature(f, QgsFeatureSink.FastInsert) 131 | feedback.setProgress(int(i * step)) 132 | 133 | if context.willLoadLayerOnCompletion(dest_id): 134 | context.layerToLoadOnCompletionDetails(dest_id).setPostProcessor( 135 | StylingPostProcessor.create(db_schema[table_name]) 136 | ) 137 | 138 | return {self.OUTPUT: dest_id} 139 | -------------------------------------------------------------------------------- /Mergin/processing/postprocessors.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | from qgis.core import QgsProcessingLayerPostProcessorInterface 5 | 6 | from ..diff import style_diff_layer 7 | 8 | 9 | class StylingPostProcessor(QgsProcessingLayerPostProcessorInterface): 10 | instance = None 11 | 12 | def __init__(self, table_schema): 13 | super().__init__() 14 | self.table_schema = table_schema 15 | 16 | def postProcessLayer(self, layer, context, feedback): 17 | style_diff_layer(layer, self.table_schema) 18 | layer.triggerRepaint() 19 | 20 | # Hack to work around sip bug! 21 | @staticmethod 22 | def create(table_schema): 23 | StylingPostProcessor.instance = StylingPostProcessor(table_schema) 24 | return StylingPostProcessor.instance 25 | -------------------------------------------------------------------------------- /Mergin/processing/provider.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | 9 | from qgis.PyQt.QtGui import QIcon 10 | 11 | from qgis.core import QgsProcessingProvider 12 | 13 | from ..utils import mm_symbol_path 14 | from .algs.create_report import CreateReport 15 | from .algs.extract_local_changes import ExtractLocalChanges 16 | from .algs.create_diff import CreateDiff 17 | from .algs.download_vector_tiles import DownloadVectorTiles 18 | 19 | 20 | class MerginProvider(QgsProcessingProvider): 21 | def __init__(self): 22 | super().__init__() 23 | self.algs = [] 24 | 25 | def id(self): 26 | return "mergin" 27 | 28 | def name(self): 29 | return "Mergin Maps" 30 | 31 | def icon(self): 32 | return QIcon(mm_symbol_path()) 33 | 34 | def load(self): 35 | self.refreshAlgorithms() 36 | return True 37 | 38 | def unload(self): 39 | pass 40 | 41 | def supportsNonFileBasedOutput(self): 42 | return False 43 | 44 | def getAlgs(self): 45 | algs = [CreateReport(), ExtractLocalChanges(), CreateDiff(), DownloadVectorTiles()] 46 | 47 | return algs 48 | 49 | def loadAlgorithms(self): 50 | self.algs = self.getAlgs() 51 | for a in self.algs: 52 | self.addAlgorithm(a) 53 | -------------------------------------------------------------------------------- /Mergin/remove_project_dialog.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox 8 | 9 | ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_remove_project_dialog.ui") 10 | 11 | 12 | class RemoveProjectDialog(QDialog): 13 | def __init__(self, project_name, parent=None): 14 | QDialog.__init__(self, parent) 15 | self.ui = uic.loadUi(ui_file, self) 16 | 17 | self.project_name = project_name 18 | self.label.setText( 19 | f"This action will remove your MerginMaps project '{self.project_name}' from the server. " 20 | "This action cannot be undone.

" 21 | "In order to delete project, enter project name in the field below and click 'Yes'." 22 | ) 23 | self.buttonBox.button(QDialogButtonBox.StandardButton.Yes).setEnabled(False) 24 | 25 | self.edit_project_name.textChanged.connect(self.project_name_changed) 26 | 27 | def project_name_changed(self, text): 28 | self.buttonBox.button(QDialogButtonBox.StandardButton.Yes).setEnabled(self.project_name == text) 29 | -------------------------------------------------------------------------------- /Mergin/repair.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | from qgis.core import QgsProject 5 | 6 | from .utils import project_grids_directory, copy_datum_shift_grids, set_qgis_project_home_ignore 7 | 8 | 9 | def fix_datum_shift_grids(mp): 10 | """ 11 | Copies datum shift grids to the MerginMaps project directory. 12 | Returns None on success and error message if some grids were not 13 | copied. 14 | """ 15 | if mp is None: 16 | return "Invalid Mergin Maps project" 17 | 18 | grids_directory = project_grids_directory(mp) 19 | if grids_directory is None: 20 | return "Failed to get destination path for grids" 21 | 22 | missed_files = copy_datum_shift_grids(grids_directory) 23 | if missed_files: 24 | return f"Following grids were not found in the QGIS folder: {','.join(missed_files)}" 25 | 26 | return None 27 | 28 | 29 | def fix_project_home_path(): 30 | """Remove home path settings from the project.""" 31 | cur_project = QgsProject.instance() 32 | set_qgis_project_home_ignore(cur_project) 33 | return None 34 | -------------------------------------------------------------------------------- /Mergin/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MerginMaps/qgis-plugin/4613a7d54bb3e4189da5e193e247857232e26104/Mergin/test/__init__.py -------------------------------------------------------------------------------- /Mergin/test/data/dem.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]] -------------------------------------------------------------------------------- /Mergin/test/data/dem.qpj: -------------------------------------------------------------------------------- 1 | GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]] 2 | -------------------------------------------------------------------------------- /Mergin/test/data/dem.tfw: -------------------------------------------------------------------------------- 1 | 0.0001000000 2 | 0.0000000000 3 | 0.0000000000 4 | -0.0001000000 5 | 18.6663479442 6 | 45.8116514376 7 | -------------------------------------------------------------------------------- /Mergin/test/data/dem.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MerginMaps/qgis-plugin/4613a7d54bb3e4189da5e193e247857232e26104/Mergin/test/data/dem.tif -------------------------------------------------------------------------------- /Mergin/test/data/dem.tifw: -------------------------------------------------------------------------------- 1 | 0.0001000000 2 | 0.0000000000 3 | 0.0000000000 4 | -0.0001000000 5 | 18.6663479442 6 | 45.8116514376 7 | -------------------------------------------------------------------------------- /Mergin/test/data/dem.wld: -------------------------------------------------------------------------------- 1 | 0.0001000000 2 | 0.0000000000 3 | 0.0000000000 4 | -0.0001000000 5 | 18.6663479442 6 | 45.8116514376 7 | -------------------------------------------------------------------------------- /Mergin/test/data/raster-tiles.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MerginMaps/qgis-plugin/4613a7d54bb3e4189da5e193e247857232e26104/Mergin/test/data/raster-tiles.mbtiles -------------------------------------------------------------------------------- /Mergin/test/data/schema_base.json: -------------------------------------------------------------------------------- 1 | { 2 | "geodiff_schema": [ 3 | { 4 | "table": "Survey_points", 5 | "columns": [ 6 | { 7 | "name": "fid", 8 | "type": "integer", 9 | "type_db": "INTEGER", 10 | "primary_key": true, 11 | "not_null": true, 12 | "auto_increment": true 13 | }, 14 | { 15 | "name": "geom", 16 | "type": "geometry", 17 | "type_db": "POINT", 18 | "geometry": { 19 | "type": "POINT", 20 | "srs_id": "3857" 21 | } 22 | }, 23 | { 24 | "name": "date", 25 | "type": "datetime", 26 | "type_db": "DATETIME" 27 | }, 28 | { 29 | "name": "notes", 30 | "type": "text", 31 | "type_db": "TEXT" 32 | }, 33 | { 34 | "name": "photo", 35 | "type": "text", 36 | "type_db": "TEXT" 37 | }, 38 | { 39 | "name": "rating", 40 | "type": "integer", 41 | "type_db": "MEDIUMINT" 42 | } 43 | ], 44 | "crs": { 45 | "srs_id": 3857, 46 | "auth_name": "EPSG", 47 | "auth_code": 3857, 48 | "wkt": "PROJCS[\"WGS 84 / Pseudo-Mercator\",GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]],PROJECTION[\"Mercator_1SP\"],PARAMETER[\"central_meridian\",0],PARAMETER[\"scale_factor\",1],PARAMETER[\"false_easting\",0],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1,AUTHORITY[\"EPSG\",\"9001\"]],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],EXTENSION[\"PROJ4\",\"+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs\"],AUTHORITY[\"EPSG\",\"3857\"]]" 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /Mergin/test/data/schema_two_tables.json: -------------------------------------------------------------------------------- 1 | { 2 | "geodiff_schema": [ 3 | { 4 | "table": "Survey_points", 5 | "columns": [ 6 | { 7 | "name": "fid", 8 | "type": "integer", 9 | "type_db": "INTEGER", 10 | "primary_key": true, 11 | "not_null": true, 12 | "auto_increment": true 13 | }, 14 | { 15 | "name": "geom", 16 | "type": "geometry", 17 | "type_db": "POINT", 18 | "geometry": { 19 | "type": "POINT", 20 | "srs_id": "3857" 21 | } 22 | }, 23 | { 24 | "name": "date", 25 | "type": "datetime", 26 | "type_db": "DATETIME" 27 | }, 28 | { 29 | "name": "notes", 30 | "type": "text", 31 | "type_db": "TEXT" 32 | }, 33 | { 34 | "name": "photo", 35 | "type": "text", 36 | "type_db": "TEXT" 37 | }, 38 | { 39 | "name": "rating", 40 | "type": "integer", 41 | "type_db": "MEDIUMINT" 42 | } 43 | ], 44 | "crs": { 45 | "srs_id": 3857, 46 | "auth_name": "EPSG", 47 | "auth_code": 3857, 48 | "wkt": "PROJCS[\"WGS 84 / Pseudo-Mercator\",GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]],PROJECTION[\"Mercator_1SP\"],PARAMETER[\"central_meridian\",0],PARAMETER[\"scale_factor\",1],PARAMETER[\"false_easting\",0],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1,AUTHORITY[\"EPSG\",\"9001\"]],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],EXTENSION[\"PROJ4\",\"+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs\"],AUTHORITY[\"EPSG\",\"3857\"]]" 49 | } 50 | }, 51 | { 52 | "table": "hotels", 53 | "columns": [ 54 | { 55 | "name": "fid", 56 | "type": "integer", 57 | "type_db": "INTEGER", 58 | "primary_key": true, 59 | "not_null": true, 60 | "auto_increment": true 61 | }, 62 | { 63 | "name": "geometry", 64 | "type": "geometry", 65 | "type_db": "POINT", 66 | "geometry": { 67 | "type": "POINT", 68 | "srs_id": "4326" 69 | } 70 | }, 71 | { 72 | "name": "name", 73 | "type": "text", 74 | "type_db": "TEXT" 75 | } 76 | ], 77 | "crs": { 78 | "srs_id": 4326, 79 | "auth_name": "EPSG", 80 | "auth_code": 4326, 81 | "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AXIS[\"Latitude\",NORTH],AXIS[\"Longitude\",EAST],AUTHORITY[\"EPSG\",\"4326\"]]" 82 | } 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /Mergin/test/data/transport_aerodrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Mergin/test/data/vector-tiles.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MerginMaps/qgis-plugin/4613a7d54bb3e4189da5e193e247857232e26104/Mergin/test/data/vector-tiles.mbtiles -------------------------------------------------------------------------------- /Mergin/test/suite.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import sys 8 | import unittest 9 | 10 | 11 | def _run_tests(test_suite, package_name): 12 | count = test_suite.countTestCases() 13 | print("########") 14 | print("{} tests has been discovered in {}".format(count, package_name)) 15 | print("########") 16 | 17 | unittest.TextTestRunner(verbosity=3, stream=sys.stdout).run(test_suite) 18 | 19 | 20 | def test_all(package="."): 21 | test_loader = unittest.defaultTestLoader 22 | test_suite = test_loader.discover(package) 23 | _run_tests(test_suite, package) 24 | 25 | 26 | if __name__ == "__main__": 27 | test_all() 28 | -------------------------------------------------------------------------------- /Mergin/test/test_help.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | import urllib.request 9 | 10 | from qgis.testing import start_app, unittest 11 | from Mergin.help import MerginHelp 12 | 13 | test_data_path = os.path.join(os.path.dirname(__file__), "data") 14 | 15 | 16 | class test_help(unittest.TestCase): 17 | @classmethod 18 | def setUpClass(cls): 19 | start_app() 20 | 21 | def test_help_urls(self): 22 | mh = MerginHelp() 23 | 24 | req = urllib.request.Request(mh.howto_attachment_widget(), method="HEAD") 25 | resp = urllib.request.urlopen(req) 26 | self.assertEqual(resp.status, 200) 27 | 28 | req = urllib.request.Request(mh.howto_background_maps(), method="HEAD") 29 | resp = urllib.request.urlopen(req) 30 | self.assertEqual(resp.status, 200) 31 | 32 | 33 | if __name__ == "__main__": 34 | nose2.main() 35 | -------------------------------------------------------------------------------- /Mergin/test/test_packaging.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | import tempfile 9 | 10 | from qgis.core import QgsRasterLayer, QgsVectorTileLayer, QgsProviderRegistry 11 | 12 | from qgis.testing import start_app, unittest 13 | from Mergin.utils import package_layer 14 | 15 | test_data_path = os.path.join(os.path.dirname(__file__), "data") 16 | 17 | 18 | class test_packaging(unittest.TestCase): 19 | @classmethod 20 | def setUpClass(cls): 21 | start_app() 22 | 23 | def test_copy_raster(self): 24 | test_data_raster_path = os.path.join(test_data_path, "dem.tif") 25 | layer = QgsRasterLayer(test_data_raster_path, "test", "gdal") 26 | self.assertTrue(layer.isValid()) 27 | source_raster_uri = layer.dataProvider().dataSourceUri() 28 | self.assertEqual(source_raster_uri, test_data_raster_path) 29 | with tempfile.TemporaryDirectory() as tmp_dir: 30 | package_layer(layer, tmp_dir) 31 | for ext in ("tif", "wld", "tfw", "prj", "qpj", "tifw"): 32 | expected_filepath = os.path.join(tmp_dir, f"dem.{ext}") 33 | self.assertTrue(os.path.exists(expected_filepath)) 34 | if ext == "tif": 35 | # Check if raster data source was updated 36 | destination_raster_uri = layer.dataProvider().dataSourceUri() 37 | self.assertEqual(destination_raster_uri, expected_filepath) 38 | 39 | def test_mbtiles_packaging(self): 40 | raster_tiles_path = os.path.join(test_data_path, "raster-tiles.mbtiles") 41 | layer = QgsRasterLayer(f"url=file://{raster_tiles_path}&type=mbtiles", "test", "wms") 42 | self.assertTrue(layer.isValid()) 43 | with tempfile.TemporaryDirectory() as tmp_dir: 44 | package_layer(layer, tmp_dir) 45 | expected_path = os.path.join(tmp_dir, "raster-tiles.mbtiles") 46 | self.assertTrue(os.path.exists(expected_path)) 47 | uri = QgsProviderRegistry.instance().decodeUri("wms", layer.source()) 48 | self.assertTrue("path" in uri, str(uri)) 49 | self.assertEqual(uri["path"], expected_path) 50 | 51 | vector_tiles_path = os.path.join(test_data_path, "vector-tiles.mbtiles") 52 | layer = QgsVectorTileLayer(f"url={vector_tiles_path}&type=mbtiles", "test") 53 | self.assertTrue(layer.isValid()) 54 | with tempfile.TemporaryDirectory() as tmp_dir: 55 | package_layer(layer, tmp_dir) 56 | expected_path = os.path.join(tmp_dir, "vector-tiles.mbtiles") 57 | self.assertTrue(os.path.exists(expected_path)) 58 | uri = QgsProviderRegistry.instance().decodeUri("vectortile", layer.source()) 59 | self.assertTrue("path" in uri) 60 | self.assertEqual(uri["path"], expected_path) 61 | 62 | 63 | if __name__ == "__main__": 64 | nose2.main() 65 | -------------------------------------------------------------------------------- /Mergin/test/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # GPLv3 license 4 | # Copyright Lutra Consulting Limited 5 | 6 | 7 | import os 8 | import json 9 | import copy 10 | import tempfile 11 | 12 | from qgis.PyQt.QtCore import QVariant 13 | from qgis.core import ( 14 | QgsProject, 15 | QgsDatumTransform, 16 | QgsCoordinateReferenceSystem, 17 | QgsCoordinateTransformContext, 18 | QgsVectorLayer, 19 | QgsWkbTypes, 20 | ) 21 | 22 | from qgis.testing import start_app, unittest 23 | from Mergin.utils import same_schema, get_datum_shift_grids, is_valid_name, create_tracking_layer 24 | 25 | test_data_path = os.path.join(os.path.dirname(__file__), "data") 26 | 27 | 28 | class test_utils(unittest.TestCase): 29 | @classmethod 30 | def setUpClass(cls): 31 | start_app() 32 | 33 | def tearDown(self): 34 | del self.base_schema 35 | del self.tables_schema 36 | 37 | def setUp(self): 38 | with open(os.path.join(test_data_path, "schema_base.json")) as f: 39 | self.base_schema = json.load(f).get("geodiff_schema") 40 | 41 | with open(os.path.join(test_data_path, "schema_two_tables.json")) as f: 42 | self.tables_schema = json.load(f).get("geodiff_schema") 43 | 44 | def test_table_added_removed(self): 45 | equal, msg = same_schema(self.base_schema, self.base_schema) 46 | self.assertTrue(equal) 47 | self.assertEqual(msg, "No schema changes") 48 | 49 | equal, msg = same_schema(self.base_schema, self.tables_schema) 50 | self.assertFalse(equal) 51 | self.assertEqual(msg, "Tables added/removed: added: hotels") 52 | 53 | equal, msg = same_schema(self.tables_schema, self.base_schema) 54 | self.assertFalse(equal) 55 | self.assertEqual(msg, "Tables added/removed: removed: hotels") 56 | 57 | def test_table_schema_changed(self): 58 | modified_schema = copy.deepcopy(self.base_schema) 59 | 60 | # change column name from fid to id 61 | modified_schema[0]["columns"][0]["name"] = "id" 62 | equal, msg = same_schema(self.base_schema, modified_schema) 63 | self.assertFalse(equal) 64 | self.assertEqual(msg, "Fields in table 'Survey_points' added/removed: added: id; removed: fid") 65 | modified_schema[0]["columns"][0]["name"] = "fid" 66 | 67 | # change column type from datetime to date 68 | modified_schema[0]["columns"][2]["type"] = "date" 69 | equal, msg = same_schema(self.base_schema, modified_schema) 70 | self.assertFalse(equal) 71 | self.assertEqual(msg, "Definition of 'date' field in 'Survey_points' table is not the same") 72 | 73 | def test_datum_shift_grids(self): 74 | grids = get_datum_shift_grids() 75 | self.assertEqual(len(grids), 0) 76 | 77 | crs_a = QgsCoordinateReferenceSystem("EPSG:27700") 78 | crs_b = QgsCoordinateReferenceSystem("EPSG:3857") 79 | ops = QgsDatumTransform.operations(crs_a, crs_b) 80 | self.assertTrue(len(ops) > 0) 81 | proj_str = ops[0].proj 82 | 83 | context = QgsCoordinateTransformContext() 84 | context.addCoordinateOperation(crs_a, crs_b, proj_str) 85 | QgsProject.instance().setTransformContext(context) 86 | 87 | # if there are no layers which use datum transformtaions nothing 88 | # should be returned 89 | grids = get_datum_shift_grids() 90 | self.assertEqual(len(grids), 0) 91 | 92 | layer = QgsVectorLayer("Point?crs=EPSG:27700", "", "memory") 93 | QgsProject.instance().addMapLayer(layer) 94 | QgsProject.instance().setCrs(crs_b) 95 | 96 | grids = get_datum_shift_grids() 97 | self.assertEqual(len(grids), 1) 98 | self.assertTrue("uk_os_OSTN15_NTv2_OSGBtoETRS.tif" in grids or "OSTN15_NTv2_OSGBtoETRS.gsb" in grids) 99 | 100 | def test_name_validation(self): 101 | test_cases = [ 102 | ("project", True), 103 | ("ProJect", True), 104 | ("Pro123ject", True), 105 | ("123PROJECT", True), 106 | ("PROJECT", True), 107 | ("project ", True), 108 | ("pro ject", True), 109 | ("proj-ect", True), 110 | ("-project", True), 111 | ("proj_ect", True), 112 | ("proj.ect", True), 113 | ("proj!ect", True), 114 | (" project", False), 115 | (".project", False), 116 | ("proj~ect", False), 117 | ("pro\ject", False), 118 | ("pro/ject", False), 119 | ("pro|ject", False), 120 | ("pro+ject", False), 121 | ("pro=ject", False), 122 | ("pro>ject", False), 123 | ("pro 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 375 10 | 130 11 | 12 | 13 | 14 | Clone Mergin Maps Project 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 0 26 | 0 27 | 28 | 29 | 30 | / 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 0 39 | 0 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 0 49 | 0 50 | 51 | 52 | 53 | 54 | 0 55 | 0 56 | 57 | 58 | 59 | 60 | 200 61 | 150 62 | 63 | 64 | 65 | Workspace 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 0 74 | 0 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 0 87 | 0 88 | 89 | 90 | 91 | 92 | 0 93 | 0 94 | 95 | 96 | 97 | 98 | 200 99 | 150 100 | 101 | 102 | 103 | Project Name 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | true 113 | 114 | 115 | Warning message goes here 116 | 117 | 118 | 119 | 120 | 121 | 122 | Qt::Vertical 123 | 124 | 125 | 126 | 20 127 | 40 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | Qt::Horizontal 136 | 137 | 138 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | buttonBox 150 | rejected() 151 | Dialog 152 | reject() 153 | 154 | 155 | 316 156 | 260 157 | 158 | 159 | 286 160 | 274 161 | 162 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /Mergin/ui/ui_config.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 652 10 | 313 11 | 12 | 13 | 14 | Mergin Maps Settings 15 | 16 | 17 | 18 | 19 | 20 | Test Connection 21 | 22 | 23 | 24 | 25 | 26 | 27 | <html><head/><body><p><span style=" font-size:9pt;">Don't have an account yet? </span><a href="https://app.merginmaps.com/register"><span style=" font-size:9pt; text-decoration: underline; color:#0000ff;">Sign up</span></a><span style=" font-size:9pt;"> now!</span></p></body></html> 28 | 29 | 30 | true 31 | 32 | 33 | 34 | 35 | 36 | 37 | Qt::Horizontal 38 | 39 | 40 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Password 51 | 52 | 53 | 54 | 55 | 56 | 57 | Warning: You may be prompted for QGIS master password 58 | 59 | 60 | 61 | 62 | 63 | 64 | https://app.merginmaps.com/ 65 | 66 | 67 | URL 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 256 76 | 76 77 | 78 | 79 | 80 | 81 | 82 | 83 | true 84 | 85 | 86 | Qt::AlignCenter 87 | 88 | 89 | 90 | 91 | 92 | 93 | Save credentials 94 | 95 | 96 | 97 | 98 | 99 | 100 | Custom Mergin Maps server 101 | 102 | 103 | 104 | 105 | 106 | 107 | Username or email 108 | 109 | 110 | 111 | 112 | 113 | 114 | Not tested yet 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | QgsPasswordLineEdit 123 | QLineEdit 124 |
qgspasswordlineedit.h
125 |
126 |
127 | 128 | username 129 | password 130 | save_credentials 131 | custom_url 132 | merginURL 133 | test_connection_btn 134 | 135 | 136 | 137 | 138 | buttonBox 139 | accepted() 140 | Dialog 141 | accept() 142 | 143 | 144 | 236 145 | 97 146 | 147 | 148 | 157 149 | 77 150 | 151 | 152 | 153 | 154 | buttonBox 155 | rejected() 156 | Dialog 157 | reject() 158 | 159 | 160 | 304 161 | 97 162 | 163 | 164 | 286 165 | 77 166 | 167 | 168 | 169 | 170 | 171 | browseForTemplateRoot() 172 | browseForProjectRoot() 173 | 174 |
175 | -------------------------------------------------------------------------------- /Mergin/ui/ui_config_file_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 269 11 | 12 | 13 | 14 | Configuration file 15 | 16 | 17 | 18 | 19 | 20 | Configuration file 21 | 22 | 23 | 24 | 25 | 26 | 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Qt::Horizontal 37 | 38 | 39 | 40 | 40 41 | 20 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Save 50 | 51 | 52 | 53 | 54 | 55 | 56 | Qt::Horizontal 57 | 58 | 59 | 60 | 40 61 | 20 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | <html><head/><body><p>See <a href="https://merginmaps.com/docs/dev/dbsync/"><span style=" text-decoration: underline; color:#0000ff;">documentation</span></a> on how to run the sync tool with generated configuration file</p></body></html> 72 | 73 | 74 | true 75 | 76 | 77 | true 78 | 79 | 80 | 81 | 82 | 83 | 84 | Qt::Vertical 85 | 86 | 87 | 88 | 20 89 | 40 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Mergin/ui/ui_create_project.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 421 10 | 281 11 | 12 | 13 | 14 | Create Mergin Project 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 0 26 | 0 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 0 39 | 0 40 | 41 | 42 | 43 | 44 | 200 45 | 150 46 | 47 | 48 | 49 | Owner 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 0 58 | 0 59 | 60 | 61 | 62 | 63 | 200 64 | 150 65 | 66 | 67 | 68 | Project Name 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 0 77 | 0 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 0 87 | 0 88 | 89 | 90 | 91 | / 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | Public 101 | 102 | 103 | 104 | 105 | 106 | 107 | Blank project 108 | 109 | 110 | true 111 | 112 | 113 | 114 | 115 | 116 | 117 | From current QGIS project 118 | 119 | 120 | 121 | 122 | 123 | 124 | Initialize from local drive 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | true 134 | 135 | 136 | 137 | 175 138 | 0 139 | 140 | 141 | 142 | Select project directory 143 | 144 | 145 | 146 | 147 | 148 | 149 | ... 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | Qt::Vertical 159 | 160 | 161 | QSizePolicy::MinimumExpanding 162 | 163 | 164 | 165 | 20 166 | 0 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | Qt::Horizontal 175 | 176 | 177 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | buttonBox 189 | rejected() 190 | Dialog 191 | reject() 192 | 193 | 194 | 316 195 | 260 196 | 197 | 198 | 286 199 | 274 200 | 201 | 202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /Mergin/ui/ui_db_selection_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 498 10 | 253 11 | 12 | 13 | 14 | Select DB connection 15 | 16 | 17 | 18 | 19 | 20 | Pick a PostgreSQL database connection 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Existing schema name for sync 31 | 32 | 33 | 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 41 | 42 | 0 43 | 44 | 45 | QLayout::SetMinimumSize 46 | 47 | 48 | 0 49 | 50 | 51 | 0 52 | 53 | 54 | 0 55 | 56 | 57 | 0 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 0 68 | 69 | 70 | QLayout::SetMinimumSize 71 | 72 | 73 | 0 74 | 75 | 76 | 0 77 | 78 | 79 | 0 80 | 81 | 82 | 0 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | Schema name for internal use (will be created) 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | Qt::Vertical 108 | 109 | 110 | 111 | 20 112 | 500 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /Mergin/ui/ui_diff_viewer_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 556 10 | 541 11 | 12 | 13 | 14 | Changes Viewer 15 | 16 | 17 | 18 | 19 | 20 | Qt::Vertical 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | QDialogButtonBox::Close 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 16 38 | 16 39 | 40 | 41 | 42 | false 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | QFrame::StyledPanel 53 | 54 | 55 | QFrame::Raised 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | QgsMessageBar 64 | QFrame 65 |
qgis.gui
66 | 1 67 |
68 | 69 | QgsMapCanvas 70 | QGraphicsView 71 |
qgis.gui
72 | 1 73 |
74 | 75 | QgsAttributeTableView 76 | QTableView 77 |
qgsattributetableview.h
78 |
79 | 80 | QTabBar 81 | QWidget 82 |
qgis.PyQt.QtWidgets
83 | 1 84 |
85 |
86 | 87 | 88 | 89 | buttonBox 90 | rejected() 91 | Dialog 92 | reject() 93 | 94 | 95 | 363 96 | 519 97 | 98 | 99 | 277 100 | 270 101 | 102 | 103 | 104 | 105 | buttonBox 106 | accepted() 107 | Dialog 108 | accept() 109 | 110 | 111 | 363 112 | 519 113 | 114 | 115 | 277 116 | 270 117 | 118 | 119 | 120 | 121 |
122 | -------------------------------------------------------------------------------- /Mergin/ui/ui_get_path_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 154 11 | 12 | 13 | 14 | WizardPage 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Question for path? 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ... 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Qt::Vertical 44 | 45 | 46 | 47 | 20 48 | 40 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /Mergin/ui/ui_gpkg_selection_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 78 11 | 12 | 13 | 14 | Select GeoPackage 15 | 16 | 17 | 18 | 19 | 20 | Pick a GeoPackage file that contains data to be synchronised 21 | 22 | 23 | true 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | QgsFileWidget 38 | QWidget 39 |
qgis.gui
40 | 1 41 |
42 |
43 | 44 | 45 |
46 | -------------------------------------------------------------------------------- /Mergin/ui/ui_packaging_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | WizardPage 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | 25 | Select layers for the new project 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Mergin/ui/ui_project_history_dock.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ProjectHistoryDockWidget 4 | 5 | 6 | 7 | 0 8 | 0 9 | 370 10 | 450 11 | 12 | 13 | 14 | Qt::LeftDockWidgetArea|Qt::RightDockWidgetArea 15 | 16 | 17 | Mergin Maps history 18 | 19 | 20 | 21 | 22 | 0 23 | 24 | 25 | 0 26 | 27 | 28 | 0 29 | 30 | 31 | 0 32 | 33 | 34 | 0 35 | 36 | 37 | 38 | 39 | 1 40 | 41 | 42 | 43 | 44 | 45 | 46 | Current project is not a Mergin project. Project history is not available. 47 | 48 | 49 | true 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Qt::CustomContextMenu 61 | 62 | 63 | false 64 | 65 | 66 | 67 | 68 | 69 | 70 | View changes 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Mergin/ui/ui_project_settings_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 481 10 | 327 11 | 12 | 13 | 14 | WizardPage 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | 25 | 26 | 27 | Project Name 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 200 36 | 100 37 | 38 | 39 | 40 | Workspace 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 0 49 | 0 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | / 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Make the project public 70 | 71 | 72 | 73 | 74 | 75 | 76 | Qt::Vertical 77 | 78 | 79 | QSizePolicy::Preferred 80 | 81 | 82 | 83 | 20 84 | 20 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | Question for path? 93 | 94 | 95 | 96 | 97 | 98 | 99 | 0 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ... 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Qt::Vertical 117 | 118 | 119 | QSizePolicy::Preferred 120 | 121 | 122 | 123 | 20 124 | 20 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | Qt::Vertical 140 | 141 | 142 | 143 | 20 144 | 40 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /Mergin/ui/ui_remove_project_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 152 11 | 12 | 13 | 14 | Remove project 15 | 16 | 17 | 18 | 19 | 20 | This action will remove your MerginMaps project from the server. This action cannot be undone. 21 | 22 | In order to delete project, enter project name in the field below and click "Yes". 23 | 24 | 25 | true 26 | 27 | 28 | 29 | 30 | 31 | 32 | Enter project name 33 | 34 | 35 | 36 | 37 | 38 | 39 | Qt::Horizontal 40 | 41 | 42 | QDialogButtonBox::No|QDialogButtonBox::Yes 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | buttonBox 52 | accepted() 53 | Dialog 54 | accept() 55 | 56 | 57 | 248 58 | 254 59 | 60 | 61 | 157 62 | 274 63 | 64 | 65 | 66 | 67 | buttonBox 68 | rejected() 69 | Dialog 70 | reject() 71 | 72 | 73 | 316 74 | 260 75 | 76 | 77 | 286 78 | 274 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Mergin/ui/ui_select_project_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 502 10 | 586 11 | 12 | 13 | 14 | Find project 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Looking for a project from a different workspace? <a href="null">Click here to switch workspace</a> 24 | 25 | 26 | Qt::TextBrowserInteraction 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 0 35 | 0 36 | 37 | 38 | 39 | Create new project 40 | 41 | 42 | false 43 | 44 | 45 | 46 | 47 | 48 | 49 | Select a project to work with 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 200 58 | 0 59 | 60 | 61 | 62 | 63 | 200 64 | 16777215 65 | 66 | 67 | 68 | Open project 69 | 70 | 71 | true 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 256 80 | 76 81 | 82 | 83 | 84 | mmLabel 85 | 86 | 87 | true 88 | 89 | 90 | Qt::AlignCenter 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | QgsFilterLineEdit 102 | QLineEdit 103 |
qgsfilterlineedit.h
104 |
105 |
106 | 107 | line_edit 108 | project_list 109 | open_project_btn 110 | new_project_btn 111 | 112 | 113 | 114 |
115 | -------------------------------------------------------------------------------- /Mergin/ui/ui_select_workspace_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 393 10 | 432 11 | 12 | 13 | 14 | Select workspace 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Select a workspace to work with 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 200 35 | 0 36 | 37 | 38 | 39 | 40 | 200 41 | 16777215 42 | 43 | 44 | 45 | Select workspace 46 | 47 | 48 | true 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 256 57 | 76 58 | 59 | 60 | 61 | mmLabel 62 | 63 | 64 | true 65 | 66 | 67 | Qt::AlignCenter 68 | 69 | 70 | 71 | 72 | 73 | 74 | Want to manage your workspaces? <a href="null">Click here to go to Mergin Maps web</a> 75 | 76 | 77 | Qt::TextBrowserInteraction 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | QgsFilterLineEdit 86 | QLineEdit 87 |
qgsfilterlineedit.h
88 |
89 |
90 | 91 | 92 |
93 | -------------------------------------------------------------------------------- /Mergin/ui/ui_status_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 416 10 | 345 11 | 12 | 13 | 14 | Project status 15 | 16 | 17 | 18 | 19 | 20 | QFrame::StyledPanel 21 | 22 | 23 | QFrame::Raised 24 | 25 | 26 | 27 | 28 | 29 | 30 | QAbstractItemView::NoEditTriggers 31 | 32 | 33 | QAbstractItemView::NoSelection 34 | 35 | 36 | 37 | 38 | 39 | 40 | Warnings 41 | 42 | 43 | 44 | 45 | 46 | 47 | true 48 | 49 | 50 | Qt::TextBrowserInteraction 51 | 52 | 53 | true 54 | 55 | 56 | 57 | 58 | 59 | 60 | View Changes 61 | 62 | 63 | 64 | 65 | 66 | 67 | This will revert all changes in your local project directory. 68 | 69 | 70 | Reset Changes 71 | 72 | 73 | 74 | 75 | 76 | 77 | Qt::Horizontal 78 | 79 | 80 | QDialogButtonBox::Close 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | QgsMessageBar 89 | QFrame 90 |
qgis.gui
91 | 1 92 |
93 |
94 | 95 | 96 | 97 | buttonBox 98 | accepted() 99 | Dialog 100 | accept() 101 | 102 | 103 | 248 104 | 254 105 | 106 | 107 | 157 108 | 274 109 | 110 | 111 | 112 | 113 | buttonBox 114 | rejected() 115 | Dialog 116 | reject() 117 | 118 | 119 | 316 120 | 260 121 | 122 | 123 | 286 124 | 274 125 | 126 | 127 | 128 | 129 |
130 | -------------------------------------------------------------------------------- /Mergin/ui/ui_sync_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SyncDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 250 11 | 12 | 13 | 14 | Mergin Maps - Synchronization 15 | 16 | 17 | 18 | 19 | 20 | 21 | 256 22 | 76 23 | 24 | 25 | 26 | true 27 | 28 | 29 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter 30 | 31 | 32 | 33 | 34 | 35 | 36 | Qt::AlignCenter 37 | 38 | 39 | 40 | 41 | 42 | 43 | 0 44 | 45 | 46 | true 47 | 48 | 49 | 50 | 51 | 52 | 53 | Cancel 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /Mergin/ui/ui_sync_direction_page.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | WizardPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 249 11 | 12 | 13 | 14 | WizardPage 15 | 16 | 17 | 18 | 19 | 20 | <html><head/><body><p>Mergin Maps DB Sync is a tool that takes care of two-way synchronisation between Mergin Maps and PostGIS databases. Read the description on <a href="https://merginmaps.com/docs/dev/dbsync/"><span style=" text-decoration: underline; color:#0000ff;">https://merginmaps.com/docs/dev/dbsync/</span></a>.</p><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> 21 | 22 | 23 | true 24 | 25 | 26 | true 27 | 28 | 29 | 30 | 31 | 32 | 33 | Initialize from Mergin Maps project 34 | 35 | 36 | 37 | 38 | 39 | 40 | Initialize from database 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Qt::Vertical 51 | 52 | 53 | 54 | 20 55 | 40 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /Mergin/workspace_selection_dialog.py: -------------------------------------------------------------------------------- 1 | # GPLv3 license 2 | # Copyright Lutra Consulting Limited 3 | 4 | import os 5 | from qgis.PyQt.QtWidgets import QDialog, QAbstractItemDelegate, QStyle 6 | from qgis.PyQt.QtCore import ( 7 | QSortFilterProxyModel, 8 | QAbstractListModel, 9 | Qt, 10 | pyqtSignal, 11 | QModelIndex, 12 | QSize, 13 | QRect, 14 | QMargins, 15 | ) 16 | from qgis.PyQt import uic 17 | from qgis.PyQt.QtGui import QPixmap, QFontMetrics, QFont 18 | 19 | from .utils import mm_logo_path 20 | 21 | ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_select_workspace_dialog.ui") 22 | 23 | 24 | class WorkspacesModel(QAbstractListModel): 25 | def __init__(self, workspaces): 26 | super(WorkspacesModel, self).__init__() 27 | self.workspaces = workspaces 28 | 29 | def rowCount(self, parent=None, *args, **kwargs): 30 | return len(self.workspaces) 31 | 32 | def data(self, index, role): 33 | workspace = self.workspaces[index.row()] 34 | if role == Qt.ItemDataRole.UserRole: 35 | return workspace 36 | if role == Qt.ItemDataRole.ToolTipRole: 37 | name = workspace["name"] 38 | desc = workspace["description"] or "" 39 | count = workspace["project_count"] 40 | return "Workspace: {}\nDescription: {}\nProjects: {}".format(name, desc, count) 41 | return workspace["name"] 42 | 43 | 44 | class WorkspaceItemDelegate(QAbstractItemDelegate): 45 | def __init__(self): 46 | super(WorkspaceItemDelegate, self).__init__() 47 | 48 | def sizeHint(self, option, index): 49 | fm = QFontMetrics(option.font) 50 | return QSize(150, fm.height() * 3 + fm.leading()) 51 | 52 | def paint(self, painter, option, index): 53 | workspace = index.data(Qt.ItemDataRole.UserRole) 54 | description = workspace["description"] 55 | if description: 56 | description = description.replace("\n", " ") 57 | nameFont = QFont(option.font) 58 | nameFont.setWeight(QFont.Weight.Bold) 59 | fm = QFontMetrics(nameFont) 60 | padding = fm.lineSpacing() // 2 61 | 62 | nameRect = QRect(option.rect) 63 | nameRect.setLeft(nameRect.left() + padding) 64 | nameRect.setTop(nameRect.top() + padding) 65 | nameRect.setRight(nameRect.right() - 50) 66 | nameRect.setHeight(fm.lineSpacing()) 67 | infoRect = QRect(option.rect) 68 | infoRect.setLeft(infoRect.left() + padding) 69 | infoRect.setTop(infoRect.bottom() - padding - fm.lineSpacing()) 70 | infoRect.setRight(infoRect.right() - padding) 71 | infoRect.setHeight(fm.lineSpacing()) 72 | borderRect = QRect(option.rect.marginsRemoved(QMargins(4, 4, 4, 4))) 73 | 74 | painter.save() 75 | if option.state & QStyle.StateFlag.State_Selected: 76 | painter.fillRect(borderRect, option.palette.highlight()) 77 | painter.drawRect(borderRect) 78 | painter.setFont(nameFont) 79 | painter.drawText(nameRect, Qt.AlignmentFlag.AlignLeading, workspace["name"]) 80 | painter.setFont(option.font) 81 | fm = QFontMetrics(QFont(option.font)) 82 | elided_description = fm.elidedText(description, Qt.TextElideMode.ElideRight, infoRect.width()) 83 | painter.drawText(infoRect, Qt.AlignmentFlag.AlignLeading, elided_description) 84 | painter.restore() 85 | 86 | 87 | class WorkspaceSelectionDialog(QDialog): 88 | manage_workspaces_clicked = pyqtSignal(str) 89 | 90 | def __init__(self, workspaces): 91 | QDialog.__init__(self) 92 | self.ui = uic.loadUi(ui_file, self) 93 | 94 | self.ui.label_logo.setPixmap(QPixmap(mm_logo_path())) 95 | 96 | self.workspace = None 97 | 98 | self.model = WorkspacesModel(workspaces) 99 | 100 | self.proxy = QSortFilterProxyModel() 101 | self.proxy.setSourceModel(self.model) 102 | self.proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) 103 | 104 | self.ui.workspace_list.setItemDelegate(WorkspaceItemDelegate()) 105 | self.ui.workspace_list.setModel(self.proxy) 106 | selectionModel = self.ui.workspace_list.selectionModel() 107 | selectionModel.selectionChanged.connect(self.on_selection_changed) 108 | self.ui.workspace_list.doubleClicked.connect(self.on_double_click) 109 | 110 | self.ui.line_edit.setShowSearchIcon(True) 111 | self.ui.line_edit.setVisible(len(workspaces) >= 5) 112 | self.ui.line_edit.textChanged.connect(self.proxy.setFilterFixedString) 113 | 114 | self.ui.select_workspace_btn.setEnabled(False) 115 | self.ui.select_workspace_btn.clicked.connect(self.on_select_workspace_clicked) 116 | self.ui.manage_workspaces_label.linkActivated.connect(self.on_manage_workspaces_clicked) 117 | 118 | def on_selection_changed(self, selected, deselected): 119 | try: 120 | index = selected.indexes()[0] 121 | except IndexError: 122 | index = QModelIndex() 123 | 124 | self.ui.select_workspace_btn.setEnabled(index.isValid()) 125 | 126 | def on_select_workspace_clicked(self): 127 | self.accept() 128 | 129 | def on_double_click(self, index): 130 | self.accept() 131 | 132 | def on_manage_workspaces_clicked(self): 133 | self.manage_workspaces_clicked.emit("/workspaces") 134 | 135 | def get_workspace(self): 136 | return self.workspace 137 | 138 | def accept(self): 139 | try: 140 | index = self.ui.workspace_list.selectedIndexes()[0] 141 | except IndexError: 142 | index = QModelIndex() 143 | if not index.isValid(): 144 | return 145 | 146 | self.workspace = self.proxy.data(index, Qt.ItemDataRole.UserRole) 147 | QDialog.accept(self) 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mergin Maps QGIS plugin 2 | 3 | 4 | 5 | 6 | 7 | 8 | [![Code Style](https://github.com/MerginMaps/qgis-plugin/actions/workflows/code_style.yml/badge.svg)](https://github.com/MerginMaps/qgis-plugin/actions/workflows/code_style.yml) 9 | [![.github/workflows/packages.yml](https://github.com/MerginMaps/qgis-plugin/actions/workflows/packages.yml/badge.svg)](https://github.com/MerginMaps/qgis-plugin/actions/workflows/packages.yml) 10 | [![Mergin Maps Plugin Tests](https://github.com/MerginMaps/qgis-plugin/actions/workflows/run-test.yml/badge.svg)](https://github.com/MerginMaps/qgis-plugin/actions/workflows/run-test.yml) 11 | 12 | QGIS plugin aims to simplify handling of [Mergin Maps](https://merginmaps.com/) projects. 13 | 14 | [Mergin Maps](https://merginmaps.com/) is a service to store, synchronise and manage GIS data and QGIS projects. Currently, [Mergin Maps](https://merginmaps.com/) is used as the back-end of Mergin Maps mobile app, an [open source](https://github.com/MerginMaps/mobile) mobile application to collect data in field. 15 | 16 |
Join our community chat
and ask questions!
17 | 18 | ## Quick start 19 | 20 | A typical workflow for setting up a project for field survey: 21 | 1. User sets up the project in QGIS desktop 22 | 2. The project gets uploaded to [Mergin Maps](https://app.merginmaps.com/) 23 | 3. Projects and data will be available on [Mergin Maps mobile app](https://merginmaps.com/) mobile application 24 | 4. Collected data from [Mergin Maps mobile app](https://merginmaps.com/) will be synchronised back to [Mergin Maps](https://app.merginmaps.com/) 25 | 5. Data will be available to view on QGIS desktop 26 | 27 | Mergin Maps QGIS plugin will assist users with steps 2 and 5. 28 | 29 | ## Documentation 30 | To learn more about how to use this plugin, see [user's documentation](https://merginmaps.com/docs/setup/install-mergin-maps-plugin-for-qgis/) 31 | -------------------------------------------------------------------------------- /docs/dev-docs.md: -------------------------------------------------------------------------------- 1 | 2 | # Developer's documentation 3 | ## Development/Testing 4 | 5 | ### On unix 6 | Download python [client](https://github.com/MerginMaps/python-api-client), install deps and 7 | link to qgis plugin: 8 | ``` 9 | ln -s /mergin/ /Mergin/mergin 10 | ``` 11 | 12 | Now link the plugin to your QGIS profile python, e.g. for MacOS 13 | ``` 14 | ln -s /Mergin/ /QGIS3/profiles/default/python/plugins/Mergin 15 | ``` 16 | 17 | ### On windows 18 | 19 | Download python [client](https://github.com/MerginMaps/python-api-client), install deps and 20 | link to qgis plugin. You might need admin privileges to run these commands: 21 | ``` 22 | mklink /J \Mergin\mergin \mergin 23 | ``` 24 | 25 | Now link the plugin to your QGIS profile python: 26 | ``` 27 | mklink /J \QGIS3\profiles\default\python\plugins\Mergin \Mergin 28 | ``` 29 | 30 | ## Production 31 | 32 | Plugin packages are built as GitHub actions for every commit. 33 | When releasing, make sure to check if [run-test.yml](../.github/workflows/run-test.yml) is using the latest QGIS release tag for auto-tests. 34 | -------------------------------------------------------------------------------- /scripts/check_all.bash: -------------------------------------------------------------------------------- 1 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 2 | PWD=`pwd` 3 | cd $DIR 4 | black -l 120 $DIR/../Mergin 5 | cd $PWD 6 | -------------------------------------------------------------------------------- /scripts/deploy_win.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | profile = "default" 5 | 6 | this_dir = os.path.dirname(os.path.realpath(__file__)) 7 | home_dir = os.path.expanduser("~") 8 | dest_dir_plug = os.path.join( 9 | home_dir, "AppData", "Roaming", "QGIS", "QGIS3", "profiles", profile, "python", "plugins", "Mergin" 10 | ) 11 | print(dest_dir_plug) 12 | src_dir_plug = os.path.join(os.path.dirname(this_dir), "Mergin") 13 | try: 14 | shutil.rmtree(dest_dir_plug) 15 | except OSError: 16 | print("Could not remove Mergin") 17 | shutil.copytree(src_dir_plug, dest_dir_plug) 18 | -------------------------------------------------------------------------------- /scripts/install_dev_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ######################################################################################################################## 4 | # Create a symbolic link to the sources in ~/.local/share/QGIS/QGIS3/profiles/default/python/plugins 5 | ######################################################################################################################## 6 | 7 | set -u # Exit if we try to use an uninitialised variable 8 | set -e # Return early if any command returns a non-0 exit status 9 | 10 | echo "ADD PLUGIN TO QGIS (dev version -- link)" 11 | 12 | PWD=`pwd` 13 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 14 | 15 | PLUGIN=Mergin 16 | SRC=$DIR/../$PLUGIN 17 | 18 | DEST=~/.local/share/QGIS/QGIS3/profiles/default/python/plugins 19 | DEST_PLUGIN=$DEST/$PLUGIN 20 | 21 | if [ ! -d "$SRC" ]; then 22 | echo "Missing directory $SRC" 23 | exit 1 24 | fi 25 | 26 | DEPS_SRC=$DIR/../../mergin-py-client 27 | 28 | if [ ! -d "$DEPS_SRC" ]; then 29 | echo "Missing dependency $DEPS_SRC. Did clone git repository?" 30 | exit 1 31 | fi 32 | 33 | rm -f $SRC/mergin 34 | ln -s $DEPS_SRC/mergin/ $SRC/mergin 35 | 36 | rm -rf $DEST_PLUGIN 37 | ln -s $SRC $DEST_PLUGIN 38 | 39 | echo "$DEST_PLUGIN created" 40 | 41 | cd $PWD 42 | -------------------------------------------------------------------------------- /scripts/update_version.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | VER=$1 4 | 5 | python3 $DIR/update_version.py --version $VER 6 | -------------------------------------------------------------------------------- /scripts/update_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import argparse 4 | 5 | 6 | def replace_in_file(filepath, regex, sub): 7 | with open(filepath, "r") as f: 8 | content = f.read() 9 | 10 | content_new = re.sub(regex, sub, content, flags=re.M) 11 | 12 | with open(filepath, "w") as f: 13 | f.write(content_new) 14 | 15 | 16 | dir_path = os.path.dirname(os.path.realpath(__file__)) 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument("--version", help="version to replace") 19 | args = parser.parse_args() 20 | ver = args.version 21 | print("using version " + ver) 22 | 23 | about_file = os.path.join(dir_path, os.pardir, "Mergin", "metadata.txt") 24 | print("patching " + about_file) 25 | replace_in_file(about_file, "version=.*", "version=" + ver) 26 | --------------------------------------------------------------------------------