Skip to content

Commit 40f2983

Browse files
authored
Merge pull request #853 from MerginMaps/dev-2026.1.0
Dev 2026.1.0
2 parents ddf3dff + cd7f1e9 commit 40f2983

File tree

5 files changed

+135
-68
lines changed

5 files changed

+135
-68
lines changed

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/project_settings_widget.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
invalid_filename_character,
3232
qvariant_to_string,
3333
escape_html_minimal,
34+
sanitize_path,
3435
)
3536

3637
ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui")
@@ -177,14 +178,13 @@ def expression_changed(self, expression):
177178
field_name = None
178179
if index.isValid():
179180
item = self.attachments_model.item(index.row(), 1)
180-
item.setData(
181-
self.edit_photo_expression.expression(),
182-
AttachmentFieldsModel.EXPRESSION,
183-
)
181+
expr = self.edit_photo_expression.expression()
182+
clean_expr = sanitize_path(expr)
183+
item.setData(clean_expr, AttachmentFieldsModel.EXPRESSION)
184184
layer = QgsProject.instance().mapLayer(item.data(AttachmentFieldsModel.LAYER_ID))
185185
field_name = item.data(AttachmentFieldsModel.FIELD_NAME)
186186

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

189189
def update_expression_edit(self, current, previous):
190190
item = self.attachments_model.item(current.row(), 1)
@@ -200,7 +200,7 @@ def update_expression_edit(self, current, previous):
200200
self.update_preview(exp, layer, field_name)
201201

202202
def update_preview(self, expression, layer, field_name):
203-
if expression == "":
203+
if not expression:
204204
self.label_preview.setText("")
205205
return
206206

@@ -219,12 +219,12 @@ def update_preview(self, expression, layer, field_name):
219219
exp = QgsExpression(expression)
220220
exp.prepare(context)
221221
if exp.hasParserError():
222-
self.label_preview.setText(f"{exp.parserErrorString()}")
222+
self.label_preview.setText(exp.parserErrorString())
223223
return
224224

225225
val = exp.evaluate(context)
226226
if exp.hasEvalError():
227-
self.label_preview.setText(f"{exp.evalErrorString()}")
227+
self.label_preview.setText(exp.evalErrorString())
228228
return
229229

230230
str_val = qvariant_to_string(val)
@@ -240,6 +240,7 @@ def update_preview(self, expression, layer, field_name):
240240
f"The file name '{filename_display}.jpg' contains an invalid character. Do not use '{invalid_char_display}' character in the file name."
241241
)
242242
return
243+
243244
config = layer.fields().field(field_name).editorWidgetSetup().config()
244245
target_dir = resolve_target_dir(layer, config)
245246
prefix = prefix_for_relative_path(

Mergin/projects_manager.py

Lines changed: 62 additions & 56 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
@@ -34,6 +35,7 @@
3435
UnsavedChangesStrategy,
3536
write_project_variables,
3637
bytes_to_human_size,
38+
push_error_message,
3739
)
3840
from .utils_auth import get_stored_mergin_server_url
3941

@@ -111,12 +113,31 @@ def create_project(self, project_name, project_dir, is_public, namespace):
111113
"Please try renaming the project."
112114
)
113115
elif e.server_code == ErrorCode.ProjectsLimitHit.value:
116+
data = e.server_response
117+
if isinstance(data, str):
118+
try:
119+
data = json.loads(data) # convert string to json
120+
except json.JSONDecodeError:
121+
data = {}
122+
123+
quota = data.get("projects_quota", "unknown")
124+
114125
msg = (
115126
"Maximum number of projects reached. Please upgrade your subscription to create new projects.\n"
116-
f"Projects quota: {e.server_response['projects_quota']}"
127+
f"Projects quota: {quota}"
117128
)
118129
elif e.server_code == ErrorCode.StorageLimitHit.value:
119-
msg = f"{e.detail}\nCurrent limit: {bytes_to_human_size(e.server_response['storage_limit'])}"
130+
data = e.server_response
131+
if isinstance(data, str):
132+
try:
133+
data = json.loads(data)
134+
except json.JSONDecodeError:
135+
data = {}
136+
137+
storage_limit = data.get("storage_limit")
138+
human_limit = bytes_to_human_size(storage_limit) if storage_limit is not None else "unknown"
139+
140+
msg = f"{e.detail}\nCurrent limit: {human_limit}"
120141

121142
QMessageBox.critical(
122143
None,
@@ -172,18 +193,7 @@ def create_project(self, project_name, project_dir, is_public, namespace):
172193
dlg.exec() # blocks until success, failure or cancellation
173194

174195
if dlg.exception:
175-
# push failed for some reason
176-
if isinstance(dlg.exception, LoginError):
177-
login_error_message(dlg.exception)
178-
elif isinstance(dlg.exception, ClientError):
179-
QMessageBox.critical(None, "Project sync", "Client error: " + str(dlg.exception))
180-
else:
181-
unhandled_exception_message(
182-
dlg.exception_details(),
183-
"Project sync",
184-
f"Something went wrong while synchronising your project {project_name}.",
185-
self.mc,
186-
)
196+
push_error_message(dlg, project_name, self.plugin, self.mc)
187197
return True
188198

189199
if not dlg.is_complete:
@@ -298,32 +308,48 @@ def reset_local_changes(self, project_dir: str, files_to_reset=None):
298308

299309
current_project_filename = os.path.normpath(QgsProject.instance().fileName())
300310
current_project_path = os.path.normpath(QgsProject.instance().absolutePath())
311+
312+
# Windows-specific behavior:
313+
# When a project is opened from this directory, QGIS may keep GPKG file handles
314+
# for a short time after closing the project. The same workaround is used in
315+
# `close_project_and_fix_pull()` (unfinished pull handling).
316+
delay = 0
301317
if current_project_path == os.path.normpath(project_dir):
302318
QgsProject.instance().clear()
319+
QApplication.processEvents()
320+
delay = 2500 # allow OS to release locked GPKG handles
303321

304-
try:
305-
self.mc.reset_local_changes(project_dir, files_to_reset)
306-
if files_to_reset:
307-
msg = f"File {files_to_reset} was successfully reset"
308-
else:
309-
msg = "Project local changes were successfully reset"
310-
QMessageBox.information(
311-
None,
312-
"Project reset local changes",
313-
msg,
314-
QMessageBox.StandardButton.Close,
315-
)
322+
def do_reset():
323+
try:
324+
self.mc.reset_local_changes(project_dir, files_to_reset)
316325

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

326-
self.open_project(os.path.dirname(current_project_filename))
350+
# Run the reset after delay (0 ms on Linux/macOS, 2500 ms on Windows)
351+
# This mirrors the pattern from unfinished pull resolution.
352+
QTimer.singleShot(delay, do_reset)
327353

328354
def sync_project(self, project_dir, project_name=None):
329355
if not project_dir:
@@ -429,27 +455,7 @@ def sync_project(self, project_dir, project_name=None):
429455
self.open_project(project_dir)
430456

431457
if dlg.exception:
432-
# push failed for some reason
433-
if isinstance(dlg.exception, LoginError):
434-
login_error_message(dlg.exception)
435-
elif isinstance(dlg.exception, ClientError):
436-
if dlg.exception.http_error == 400 and "Another process" in dlg.exception.detail:
437-
# To note we check for a string since error in flask doesn't return server error code
438-
msg = "Somebody else is syncing, please try again later"
439-
elif dlg.exception.server_code == ErrorCode.StorageLimitHit.value:
440-
msg = f"{dlg.exception.detail}\nCurrent limit: {bytes_to_human_size(dlg.exception.server_response['storage_limit'])}"
441-
else:
442-
msg = str(dlg.exception)
443-
QMessageBox.critical(None, "Project sync", "Client error: \n" + msg)
444-
elif isinstance(dlg.exception, AuthTokenExpiredError):
445-
self.plugin.auth_token_expired()
446-
else:
447-
unhandled_exception_message(
448-
dlg.exception_details(),
449-
"Project sync",
450-
f"Something went wrong while synchronising your project {project_name}.",
451-
self.mc,
452-
)
458+
push_error_message(dlg, project_name, self.plugin, self.mc)
453459
return
454460

455461
if dlg.is_complete:

Mergin/utils.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676

7777
try:
7878
from .mergin.common import ClientError, ErrorCode, LoginError, InvalidProject
79-
from .mergin.client import MerginClient, ServerType
79+
from .mergin.client import MerginClient, ServerType, AuthTokenExpiredError
8080
from .mergin.client_pull import (
8181
download_project_async,
8282
download_project_is_running,
@@ -1312,7 +1312,16 @@ def is_valid_name(name):
13121312
"""
13131313
return (
13141314
re.match(
1315-
r".*[\@\#\$\%\^\&\*\(\)\{\}\[\]\?\'\"`,;\:\+\=\~\\\/\|\<\>].*|^[\s^\.].*$|^CON$|^PRN$|^AUX$|^NUL$|^COM\d$|^LPT\d|^support$|^helpdesk$|^merginmaps$|^lutraconsulting$|^mergin$|^lutra$|^input$|^admin$|^sales$|^$",
1315+
r".*[@#$%\^&\*\(\)\{\}\[\]\?\'\"`,;:\+\=\~\\\/\|<>].*"
1316+
r"|^[\s^\.].*$"
1317+
r"|\.+$"
1318+
r"|\s+$"
1319+
r"|[\x00-\x1F]"
1320+
r"|^\.$|^\.\.$"
1321+
r"|^CON$|^PRN$|^AUX$|^NUL$"
1322+
r"|^COM\d$|^LPT\d$"
1323+
r"|^support$|^helpdesk$|^merginmaps$|^lutraconsulting$"
1324+
r"|^mergin$|^lutra$|^input$|^admin$|^sales$|^$",
13161325
name,
13171326
re.IGNORECASE,
13181327
)
@@ -1715,3 +1724,48 @@ def escape_html_minimal(s: str) -> str:
17151724
for char, escaped in replacements.items():
17161725
s = s.replace(char, escaped)
17171726
return s
1727+
1728+
1729+
def sanitize_path(expr: str) -> str:
1730+
if not expr:
1731+
return expr
1732+
parts = expr.split("/")
1733+
cleaned = [p.rstrip() for p in parts]
1734+
return "/".join(cleaned)
1735+
1736+
1737+
def storage_limit_fail(exc):
1738+
data = exc.server_response
1739+
if isinstance(data, str):
1740+
try:
1741+
data = json.loads(data)
1742+
except json.JSONDecodeError:
1743+
data = {}
1744+
storage_limit = data.get("storage_limit")
1745+
human_limit = bytes_to_human_size(storage_limit) if storage_limit is not None else "unknown"
1746+
return f"{exc.detail}\nCurrent limit: {human_limit}"
1747+
1748+
1749+
def push_error_message(dlg, project_name, plugin, mc):
1750+
if isinstance(dlg.exception, LoginError):
1751+
login_error_message(dlg.exception)
1752+
elif isinstance(dlg.exception, ClientError):
1753+
exc = dlg.exception
1754+
1755+
if exc.http_error == 400 and "Another process" in exc.detail:
1756+
msg = "Somebody else is syncing, please try again later"
1757+
elif exc.server_code == ErrorCode.StorageLimitHit.value:
1758+
msg = storage_limit_fail(exc)
1759+
else:
1760+
msg = str(exc)
1761+
1762+
QMessageBox.critical(None, "Project sync", "Client error: \n" + msg)
1763+
elif isinstance(dlg.exception, AuthTokenExpiredError):
1764+
plugin.auth_token_expired()
1765+
else:
1766+
unhandled_exception_message(
1767+
dlg.exception_details(),
1768+
"Project sync",
1769+
f"Something went wrong while synchronising your project {project_name}.",
1770+
mc,
1771+
)

Mergin/validation.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,6 @@ def check_project_relations(self):
299299

300300
# check fields are unique
301301
self._check_field_unique(parent_layer, parent_fields)
302-
self._check_field_unique(child_layer, child_fields)
303302

304303
# check that fields used in relation are not primary keys
305304
if parent_layer.dataProvider().storageType() == "GPKG":
@@ -330,7 +329,6 @@ def check_value_relation(self):
330329
# and is not a primary key
331330
if child_layer.dataProvider().storageType() == "GPKG":
332331
idx = child_layer.fields().indexFromName(str(cfg["Key"]))
333-
self._check_field_unique(child_layer, [idx])
334332
self._check_primary_keys(child_layer, [idx])
335333

336334
def _check_field_unique(self, layer, fields):

0 commit comments

Comments
 (0)