Skip to content

Commit 887c76a

Browse files
committed
Merge remote-tracking branch 'origin/dev-2025.4.1' into export_import_auth
2 parents 5a7ac12 + 40f2983 commit 887c76a

File tree

8 files changed

+163
-74
lines changed

8 files changed

+163
-74
lines changed

.github/workflows/packages.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ on:
1313
default: '2.0.4'
1414
type: string
1515
env:
16-
PYTHON_API_CLIENT_VER: ${{ inputs.REQUESTED_PYTHON_API_CLIENT_VER || '0.10.5' }}
16+
PYTHON_API_CLIENT_VER: ${{ inputs.REQUESTED_PYTHON_API_CLIENT_VER || '0.11.0' }}
1717
GEODIFF_VER: ${{ inputs.REQUESTED_GEODIFF_VER || '2.0.4' }}
1818
PYTHON_VER: "38"
1919
PLUGIN_NAME: Mergin

Mergin/create_project_wizard.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ def check_input(self):
170170
if not self.project_workspace_cbo.currentData(Qt.ItemDataRole.UserRole):
171171
self.create_warning("You do not have permissions to create a project in this workspace!")
172172
return
173+
173174
proj_name = self.project_name_ledit.text().strip()
174175
if not proj_name:
175176
self.create_warning("Project name missing!")
@@ -181,6 +182,7 @@ def check_input(self):
181182
path_text = self.path_ledit.text()
182183
if not path_text:
183184
return
185+
184186
warn = ""
185187
if not os.path.exists(path_text):
186188
self.create_warning("The path does not exist")
@@ -191,6 +193,11 @@ def check_input(self):
191193
else:
192194
proj_dir = os.path.join(path_text, proj_name)
193195

196+
for part in Path(proj_dir).parts:
197+
if part != part.rstrip():
198+
self.create_warning(f"The folder name '{part}' cannot end with a space!")
199+
return
200+
194201
if os.path.exists(proj_dir):
195202
is_mergin = check_mergin_subdirs(proj_dir)
196203
else:
@@ -199,6 +206,7 @@ def check_input(self):
199206
if not self.for_current_proj:
200207
if os.path.exists(proj_dir):
201208
warn = f"Selected directory:\n{proj_dir}\nalready exists."
209+
202210
if not warn and not os.path.isabs(proj_dir):
203211
warn = "Incorrect project name!"
204212
if not warn and is_mergin:

Mergin/metadata.txt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,32 @@
22
[general]
33
name=Mergin
44
qgisMinimumVersion=3.22
5-
qgisMaximumVersion=3.99
5+
qgisMaximumVersion=4.99
66
description=Handle Mergin Maps projects
7-
version=2025.3.3
7+
version=2025.4.0
88
author=Lutra Consulting
99
email=info@merginmaps.com
1010
about=Mergin Maps is a repository for storing and tracking changes to QGIS projects/data and has its mobile app for field geo-surveys. With this plugin, users can upload and sync their data to Mergin Maps service.
1111
; end of mandatory metadata
1212

1313
; start of optional metadata
14-
changelog=2025.3.3
14+
changelog=2025.4.0
15+
- Fix synchronisation issues when using OneDrive
16+
- Show downloaded projects first in the browser
17+
- Dropped support for out of date server version (< 2023)
18+
- Improved link visibility on dark background
19+
- Fixed deleting project locally on Windows
20+
- Fixed cleanup of obsolete files remain in .mergin folder
21+
- Fixed error when syncing newly created project
22+
- Fixed local paths handling
23+
- Fix handling of https prefix in custom mergin maps server url
24+
- Added better unhandled exception dialog during project sync
25+
- Improved validations on forbidden characters, online basemaps and SVG rules
26+
- Improved photo name preview
27+
<p>2025.3.4
28+
- Introducing photo sketching for public use
29+
- Added support for QGIS 4+
30+
<p>2025.3.3
1531
- Fixed issue with the access token structure
1632
<p>2025.3.2
1733
- Added the ability to control layer sort order in the mobile app

Mergin/project_settings_widget.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727
create_tracking_layer,
2828
create_map_sketches_layer,
2929
set_tracking_layer_flags,
30-
is_experimental_plugin_enabled,
3130
remove_prefix,
3231
invalid_filename_character,
3332
qvariant_to_string,
3433
escape_html_minimal,
34+
sanitize_path,
3535
)
3636

3737
ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui")
@@ -133,12 +133,6 @@ def __init__(self, parent=None):
133133
self.attachment_fields.selectionModel().currentChanged.connect(self.update_expression_edit)
134134
self.edit_photo_expression.expressionChanged.connect(self.expression_changed)
135135

136-
if is_experimental_plugin_enabled():
137-
self.groupBox_photo_sketching.setTitle(self.groupBox_photo_sketching.title() + " (Experimental)")
138-
else:
139-
# Hide by default
140-
self.groupBox_photo_sketching.setVisible(False)
141-
142136
def get_sync_dir(self):
143137
abs_path = QFileDialog.getExistingDirectory(
144138
None,
@@ -184,14 +178,13 @@ def expression_changed(self, expression):
184178
field_name = None
185179
if index.isValid():
186180
item = self.attachments_model.item(index.row(), 1)
187-
item.setData(
188-
self.edit_photo_expression.expression(),
189-
AttachmentFieldsModel.EXPRESSION,
190-
)
181+
expr = self.edit_photo_expression.expression()
182+
clean_expr = sanitize_path(expr)
183+
item.setData(clean_expr, AttachmentFieldsModel.EXPRESSION)
191184
layer = QgsProject.instance().mapLayer(item.data(AttachmentFieldsModel.LAYER_ID))
192185
field_name = item.data(AttachmentFieldsModel.FIELD_NAME)
193186

194-
self.update_preview(expression, layer, field_name)
187+
self.update_preview(clean_expr, layer, field_name)
195188

196189
def update_expression_edit(self, current, previous):
197190
item = self.attachments_model.item(current.row(), 1)
@@ -207,7 +200,7 @@ def update_expression_edit(self, current, previous):
207200
self.update_preview(exp, layer, field_name)
208201

209202
def update_preview(self, expression, layer, field_name):
210-
if expression == "":
203+
if not expression:
211204
self.label_preview.setText("")
212205
return
213206

@@ -226,12 +219,12 @@ def update_preview(self, expression, layer, field_name):
226219
exp = QgsExpression(expression)
227220
exp.prepare(context)
228221
if exp.hasParserError():
229-
self.label_preview.setText(f"{exp.parserErrorString()}")
222+
self.label_preview.setText(exp.parserErrorString())
230223
return
231224

232225
val = exp.evaluate(context)
233226
if exp.hasEvalError():
234-
self.label_preview.setText(f"{exp.evalErrorString()}")
227+
self.label_preview.setText(exp.evalErrorString())
235228
return
236229

237230
str_val = qvariant_to_string(val)
@@ -247,6 +240,7 @@ def update_preview(self, expression, layer, field_name):
247240
f"The file name '{filename_display}.jpg' contains an invalid character. Do not use '{invalid_char_display}' character in the file name."
248241
)
249242
return
243+
250244
config = layer.fields().field(field_name).editorWidgetSetup().config()
251245
target_dir = resolve_target_dir(layer, config)
252246
prefix = prefix_for_relative_path(

Mergin/projects_manager.py

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from urllib.parse import urlparse
66
from pathlib import Path
77
import posixpath
8+
import json
89

910
from qgis.core import QgsProject, Qgis, QgsApplication
1011
from qgis.utils import iface, OverrideCursor
@@ -29,6 +30,7 @@
2930
login_error_message,
3031
same_dir,
3132
send_logs,
33+
storage_limit_fail,
3234
unhandled_exception_message,
3335
unsaved_project_check,
3436
UnsavedChangesStrategy,
@@ -37,6 +39,7 @@
3739
get_push_changes_batch,
3840
SYNC_ATTEMPTS,
3941
SYNC_ATTEMPT_WAIT,
42+
push_error_message,
4043
)
4144
from .utils_auth import get_stored_mergin_server_url
4245

@@ -114,12 +117,31 @@ def create_project(self, project_name, project_dir, is_public, namespace):
114117
"Please try renaming the project."
115118
)
116119
elif e.server_code == ErrorCode.ProjectsLimitHit.value:
120+
data = e.server_response
121+
if isinstance(data, str):
122+
try:
123+
data = json.loads(data) # convert string to json
124+
except json.JSONDecodeError:
125+
data = {}
126+
127+
quota = data.get("projects_quota", "unknown")
128+
117129
msg = (
118130
"Maximum number of projects reached. Please upgrade your subscription to create new projects.\n"
119-
f"Projects quota: {e.server_response['projects_quota']}"
131+
f"Projects quota: {quota}"
120132
)
121133
elif e.server_code == ErrorCode.StorageLimitHit.value:
122-
msg = f"{e.detail}\nCurrent limit: {bytes_to_human_size(e.server_response['storage_limit'])}"
134+
data = e.server_response
135+
if isinstance(data, str):
136+
try:
137+
data = json.loads(data)
138+
except json.JSONDecodeError:
139+
data = {}
140+
141+
storage_limit = data.get("storage_limit")
142+
human_limit = bytes_to_human_size(storage_limit) if storage_limit is not None else "unknown"
143+
144+
msg = f"{e.detail}\nCurrent limit: {human_limit}"
123145

124146
QMessageBox.critical(
125147
None,
@@ -176,18 +198,7 @@ def create_project(self, project_name, project_dir, is_public, namespace):
176198
dlg.exec() # blocks until success, failure or cancellation
177199

178200
if dlg.exception:
179-
# push failed for some reason
180-
if isinstance(dlg.exception, LoginError):
181-
login_error_message(dlg.exception)
182-
elif isinstance(dlg.exception, ClientError):
183-
QMessageBox.critical(None, "Project sync", "Client error: " + str(dlg.exception))
184-
else:
185-
unhandled_exception_message(
186-
dlg.exception_details(),
187-
"Project sync",
188-
f"Something went wrong while synchronising your project {project_name}.",
189-
self.mc,
190-
)
201+
push_error_message(dlg, project_name, self.plugin, self.mc)
191202
return True
192203

193204
if not dlg.is_complete:
@@ -302,32 +313,48 @@ def reset_local_changes(self, project_dir: str, files_to_reset=None):
302313

303314
current_project_filename = os.path.normpath(QgsProject.instance().fileName())
304315
current_project_path = os.path.normpath(QgsProject.instance().absolutePath())
316+
317+
# Windows-specific behavior:
318+
# When a project is opened from this directory, QGIS may keep GPKG file handles
319+
# for a short time after closing the project. The same workaround is used in
320+
# `close_project_and_fix_pull()` (unfinished pull handling).
321+
delay = 0
305322
if current_project_path == os.path.normpath(project_dir):
306323
QgsProject.instance().clear()
324+
QApplication.processEvents()
325+
delay = 2500 # allow OS to release locked GPKG handles
307326

308-
try:
309-
self.mc.reset_local_changes(project_dir, files_to_reset)
310-
if files_to_reset:
311-
msg = f"File {files_to_reset} was successfully reset"
312-
else:
313-
msg = "Project local changes were successfully reset"
314-
QMessageBox.information(
315-
None,
316-
"Project reset local changes",
317-
msg,
318-
QMessageBox.StandardButton.Close,
319-
)
327+
def do_reset():
328+
try:
329+
self.mc.reset_local_changes(project_dir, files_to_reset)
320330

321-
except Exception as e:
322-
msg = f"Failed to reset local changes:\n\n{str(e)}"
323-
QMessageBox.critical(
324-
None,
325-
"Project reset local changes",
326-
msg,
327-
QMessageBox.StandardButton.Close,
328-
)
331+
if files_to_reset:
332+
msg = f"File {files_to_reset} was successfully reset"
333+
else:
334+
msg = "Project local changes were successfully reset"
335+
336+
QMessageBox.information(
337+
None,
338+
"Project reset local changes",
339+
msg,
340+
QMessageBox.StandardButton.Close,
341+
)
342+
343+
except Exception as e:
344+
msg = f"Failed to reset local changes:\n\n{str(e)}"
345+
QMessageBox.critical(
346+
None,
347+
"Project reset local changes",
348+
msg,
349+
QMessageBox.StandardButton.Close,
350+
)
351+
352+
# Reopen the project after successful or failed reset
353+
self.open_project(os.path.dirname(current_project_filename))
329354

330-
self.open_project(os.path.dirname(current_project_filename))
355+
# Run the reset after delay (0 ms on Linux/macOS, 2500 ms on Windows)
356+
# This mirrors the pattern from unfinished pull resolution.
357+
QTimer.singleShot(delay, do_reset)
331358

332359
def sync_project(self, project_dir, project_name=None):
333360
if not project_dir:
@@ -445,7 +472,7 @@ def sync_project(self, project_dir, project_name=None):
445472
# To note we check for a string since error in flask doesn't return server error code
446473
msg = "Somebody else is syncing, please try again later"
447474
elif dlg.exception.server_code == ErrorCode.StorageLimitHit.value:
448-
msg = f"{dlg.exception.detail}\nCurrent limit: {bytes_to_human_size(dlg.exception.server_response['storage_limit'])}"
475+
msg = storage_limit_fail(dlg.exception)
449476
else:
450477
msg = str(dlg.exception)
451478
QMessageBox.critical(None, "Project sync", "Client error: \n" + msg)

Mergin/ui/ui_project_config.ui

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@
474474
<item>
475475
<widget class="QGroupBox" name="groupBox_photo_sketching">
476476
<property name="title">
477-
<string>Photo sketching</string>
477+
<string>Photo sketching [Preview]</string>
478478
</property>
479479
<layout class="QGridLayout" name="gridLayout_7">
480480
<item row="1" column="0">
@@ -490,7 +490,10 @@
490490
<item row="0" column="0">
491491
<widget class="QLabel" name="label_13">
492492
<property name="text">
493-
<string>Photo sketching lets mobile app users modify captured photos by adding freehand annotations</string>
493+
<string>Photo sketching lets mobile app users draw freehand annotations directly on captured photos. This feature is in Preview: it works, but may still have some issues.</string>
494+
</property>
495+
<property name="wordWrap">
496+
<bool>true</bool>
494497
</property>
495498
</widget>
496499
</item>

0 commit comments

Comments
 (0)