├── .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 |
17 |
--------------------------------------------------------------------------------
/Mergin/images/default/MM_symbol_COLOR_no_padding.svg:
--------------------------------------------------------------------------------
1 |
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 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/cloud-download.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/cloud.svg:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/copy.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/database-cog.svg:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/dots.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/explore.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/file-description.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/file-diff.svg:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/file-export.svg:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/file-plus.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/first-aid-kit.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/folder-plus.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/history.svg:
--------------------------------------------------------------------------------
1 |
2 |
52 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/list.svg:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/pencil.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/plus.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/refresh.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/repeat.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/replace.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/revert-changes.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/search.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/settings.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/square-plus.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/table.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/trash.svg:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/user.svg:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Mergin/images/default/tabler_icons/users.svg:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Mergin/images/white/MM_logo_HORIZ_COLOR_INVERSE_VECTOR.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/Mergin/images/white/MM_symbol_COLOR_INVERSE_no_padding.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/Mergin/images/white/MM_symbol_COLOR_INVERSE_small_padding.svg:
--------------------------------------------------------------------------------
1 |
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 |
91 |
--------------------------------------------------------------------------------
/Mergin/images/white/tabler_icons/dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Mergin/images/white/tabler_icons/file-description.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Mergin/images/white/tabler_icons/file-diff.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Mergin/images/white/tabler_icons/file-export.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
66 | 1
67 |
68 |
69 | QgsMapCanvas
70 | QGraphicsView
71 |
72 | 1
73 |
74 |
75 | QgsAttributeTableView
76 | QTableView
77 |
78 |
79 |
80 | QTabBar
81 | QWidget
82 |
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 |
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 |
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 |
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 |
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 | [](https://github.com/MerginMaps/qgis-plugin/actions/workflows/code_style.yml)
9 | [](https://github.com/MerginMaps/qgis-plugin/actions/workflows/packages.yml)
10 | [](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 |
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 |
--------------------------------------------------------------------------------