From e0657b6d1a8d6a37c6e1b30d6ce83a1039ab292f Mon Sep 17 00:00:00 2001 From: gaetanbrl Date: Thu, 4 Jun 2026 14:56:55 +0200 Subject: [PATCH 1/3] save, create and open config from qgis app --- src/routes/qgis.py | 209 ++++++++++++++++++++++++++++----- src/static/js/mviewerstudio.js | 46 +++++--- 2 files changed, 212 insertions(+), 43 deletions(-) diff --git a/src/routes/qgis.py b/src/routes/qgis.py index e4d365ac..ea4daec1 100644 --- a/src/routes/qgis.py +++ b/src/routes/qgis.py @@ -1,16 +1,22 @@ +from datetime import datetime from pathlib import Path +from uuid import uuid4 from tempfile import TemporaryDirectory from os import mkdir, path, walk +from urllib.parse import quote +import xml.etree.ElementTree as ET import requests -from flask import current_app, jsonify, request +from flask import current_app, jsonify, redirect, request from flask.typing import ResponseReturnValue from werkzeug.exceptions import BadRequest, MethodNotAllowed from werkzeug.utils import secure_filename -from ..utils.config_utils import read_static_app_conf +from ..utils.config_utils import Config, normalize_url_part, read_static_app_conf +from ..utils.login_utils import current_user from .shared import ( _build_qgis_service_base_url, + _config_register, _extract_qgs_zip, _is_allowed_proxy_origin, _is_get_capabilities_url, @@ -21,8 +27,15 @@ from qgisxmviewer import create_mviewer_xml_text_from_wms_capabilities +RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +DC_NAMESPACE = "http://purl.org/dc/elements/1.1/" + +ET.register_namespace("rdf", RDF_NAMESPACE) +ET.register_namespace("dc", DC_NAMESPACE) + def _configured_qgis_projects_url() -> str: + """Return the configured QGIS projects base URL with a trailing slash.""" qgis_config = read_static_app_conf().get("qgis", {}) base_url = (qgis_config.get("url") or "").strip() if not base_url: @@ -31,11 +44,13 @@ def _configured_qgis_projects_url() -> str: def _is_configured_qgis_url(url: str) -> bool: + """Return ``True`` when the URL targets the configured QGIS projects base.""" configured_url = _configured_qgis_projects_url() return url.startswith(configured_url) def _project_capabilities_url(project_name: str) -> str: + """Build the QGIS Server WMS GetCapabilities URL for a stored project.""" if not project_name: raise BadRequest("Missing QGIS project name") return ( @@ -45,6 +60,7 @@ def _project_capabilities_url(project_name: str) -> str: def _mviewer_xml_from_capabilities_url(capabilities_url: str) -> str: + """Fetch a WMS GetCapabilities document and convert it to mviewer XML.""" if not capabilities_url: raise BadRequest("Missing GetCapabilities URL") if not _is_get_capabilities_url(capabilities_url): @@ -81,39 +97,145 @@ def _mviewer_xml_from_capabilities_url(capabilities_url: str) -> str: ) +def _store_uploaded_qgis_project() -> dict: + """Persist an uploaded QGIS project or archive and return its project payload.""" + qgs_dir = current_app.config.get("QGS_FOLDER") + if not qgs_dir: + raise BadRequest("Missing QGS folder configuration") + + uploaded_file = request.files.get("file") + if not uploaded_file or not uploaded_file.filename: + raise BadRequest("Missing QGS file") + + filename = secure_filename(uploaded_file.filename) + lower_filename = filename.lower() + if not lower_filename.endswith((".qgs", ".zip", ".qgz")): + raise BadRequest("Only .qgs files, .zip archives or .qgz archives are supported") + + if not path.exists(qgs_dir): + mkdir(qgs_dir) + + if lower_filename.endswith((".zip", ".qgz")): + _, extracted_projects = _extract_qgs_zip(uploaded_file, qgs_dir, filename) + project_payload = extracted_projects[0].copy() + project_payload["projects"] = extracted_projects + project_payload["archiveName"] = filename + return project_payload + + project_payload = _store_qgs_file(uploaded_file, qgs_dir, filename) + project_payload["projects"] = [project_payload.copy()] + return project_payload + + +def _ensure_config_metadata(xml_content: str, project_name: str) -> str: + """Inject the minimum Studio metadata required to persist generated XML.""" + xml_root = ET.fromstring(xml_content) + app_conf = read_static_app_conf() + + if not xml_root.get("mviewerversion") and app_conf.get("mviewer_version"): + xml_root.set("mviewerversion", str(app_conf["mviewer_version"])) + if not xml_root.get("mviewerstudioversion") and app_conf.get("mviewerstudio_version"): + xml_root.set("mviewerstudioversion", str(app_conf["mviewerstudio_version"])) + + application_node = xml_root.find("application") + app_title = (application_node.get("title") if application_node is not None else "") or ( + project_name or "Projet QGIS" + ) + creator = current_user.username if current_user else "anonymous" + publisher = ( + current_user.normalize_name + if current_user + else current_app.config.get("DEFAULT_ORG", "public") + ) + + metadata_node = xml_root.find("metadata") + if metadata_node is None: + metadata_node = ET.Element("metadata") + xml_root.insert(0, metadata_node) + + rdf_root = metadata_node.find(f"{{{RDF_NAMESPACE}}}RDF") + if rdf_root is None: + rdf_root = ET.SubElement(metadata_node, f"{{{RDF_NAMESPACE}}}RDF") + + rdf_description = rdf_root.find(f"{{{RDF_NAMESPACE}}}Description") + if rdf_description is None: + rdf_description = ET.SubElement( + rdf_root, + f"{{{RDF_NAMESPACE}}}Description", + {"{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about": ""}, + ) + + metadata_defaults = { + "title": app_title, + "creator": creator, + "identifier": uuid4().hex[:12], + "keywords": "qgis,import", + "publisher": publisher, + "description": f"Configuration générée depuis QGIS Server pour le projet {app_title}", + "date": datetime.utcnow().isoformat(timespec="seconds") + "Z", + "relation": "", + } + + for key, default_value in metadata_defaults.items(): + node = rdf_description.find(f"{{{DC_NAMESPACE}}}{key}") + if node is None: + node = ET.SubElement(rdf_description, f"{{{DC_NAMESPACE}}}{key}") + if node.text is None or not node.text.strip(): + node.text = default_value + + return ET.tostring(xml_root, encoding="unicode") + + +def _create_config_from_qgis_capabilities( + capabilities_url: str, project_name: str +) -> tuple[dict, str]: + """Create and register a Studio config from a QGIS Server capabilities URL.""" + xml_content = _mviewer_xml_from_capabilities_url(capabilities_url) + xml_content = _ensure_config_metadata(xml_content, project_name) + + config = Config(xml=xml_content, app=current_app) + if not config or not config.xml: + raise BadRequest("Unable to create config from QGIS capabilities") + + config.git.commit_changes("Creation from QGIS Server capabilities") + config_data = config.as_data() + current_config = _config_register().read_json(config_data.id) + if not current_config: + _config_register().add(config_data.as_dict()) + else: + _config_register().update(config_data.as_dict()) + + return config_data.as_dict(), xml_content + + +def _absolute_config_url(config_path: str) -> str: + """Return the public absolute URL of a stored Studio XML config.""" + app_conf = read_static_app_conf() + mviewer_instance = (app_conf.get("mviewer_instance") or "").rstrip("/") + conf_path = normalize_url_part(app_conf.get("conf_path_from_mviewer", "")) + relative_path = "/".join( + part for part in [conf_path, normalize_url_part(config_path)] if part + ) + return f"{mviewer_instance}/{relative_path}" if mviewer_instance else f"/{relative_path}" + + +def _studio_open_config_url(config_url: str) -> str: + """Return the Studio URL that opens a config through the ``config`` parameter.""" + app_prefix = current_app.config.get("MVIEWERSTUDIO_URL_PATH_PREFIX", "").strip("/") + base_url = request.url_root.rstrip("/") + if app_prefix: + base_url = f"{base_url}/{app_prefix}" + return f"{base_url}/index.html#?config={quote(config_url, safe='/:?&=%')}" + + @basic_store.route("/api/app/qgis/projects", methods=["GET", "POST"]) def list_stored_qgs_configs() -> ResponseReturnValue: + """List stored QGIS projects or upload a new project/archive.""" qgs_dir = current_app.config.get("QGS_FOLDER") if request.method == "POST": - if not qgs_dir: - raise BadRequest("Missing QGS folder configuration") - - uploaded_file = request.files.get("file") - if not uploaded_file or not uploaded_file.filename: - raise BadRequest("Missing QGS file") - - filename = secure_filename(uploaded_file.filename) - lower_filename = filename.lower() - if not lower_filename.endswith((".qgs", ".zip", ".qgz")): - raise BadRequest( - "Only .qgs files, .zip archives or .qgz archives are supported" - ) - - if not path.exists(qgs_dir): - mkdir(qgs_dir) - - if lower_filename.endswith((".zip", ".qgz")): - _, extracted_projects = _extract_qgs_zip(uploaded_file, qgs_dir, filename) - response_payload = extracted_projects[0].copy() - response_payload["success"] = True - response_payload["projects"] = extracted_projects - response_payload["archiveName"] = filename - return jsonify(response_payload) - - response_payload = _store_qgs_file(uploaded_file, qgs_dir, filename) + response_payload = _store_uploaded_qgis_project() response_payload["success"] = True - response_payload["projects"] = [response_payload.copy()] return jsonify(response_payload) if not qgs_dir or not path.exists(qgs_dir): @@ -131,6 +253,7 @@ def list_stored_qgs_configs() -> ResponseReturnValue: @basic_store.route("/api/app/qgis/capabilities", methods=["POST"]) def import_qgis_capabilities() -> ResponseReturnValue: + """Convert a QGIS Server GetCapabilities URL to mviewer XML.""" payload = request.get_json(silent=True) or {} capabilities_url = payload.get("url", "").strip() xml_content = _mviewer_xml_from_capabilities_url(capabilities_url) @@ -139,6 +262,7 @@ def import_qgis_capabilities() -> ResponseReturnValue: @basic_store.route("/api/app/qgis/project", methods=["POST"]) def import_qgis_project() -> ResponseReturnValue: + """Upload a QGIS project, resolve its GetCapabilities URL, and return XML.""" qgs_dir = current_app.config.get("QGS_FOLDER") if not qgs_dir: raise BadRequest("Missing QGS folder configuration") @@ -167,3 +291,30 @@ def import_qgis_project() -> ResponseReturnValue: response.headers["X-Qgis-Project-Name"] = project_payload["projectName"] response.headers["X-Qgis-Capabilities-Url"] = capabilities_url return response + + +@basic_store.route("/api/app/qgis/projects/open", methods=["POST"]) +def create_and_open_qgis_project_config() -> ResponseReturnValue: + """Create a Studio config from an uploaded QGIS project and expose open URLs.""" + project_payload = _store_uploaded_qgis_project() + capabilities_url = _project_capabilities_url(project_payload["projectName"]) + config_data, _ = _create_config_from_qgis_capabilities( + capabilities_url, project_payload["projectName"] + ) + config_url = _absolute_config_url(config_data["url"]) + studio_url = _studio_open_config_url(config_url) + + if request.args.get("redirect", "").lower() in {"1", "true", "yes"}: + return redirect(studio_url) + + return jsonify( + { + "success": True, + "project": project_payload, + "capabilitiesUrl": capabilities_url, + "filepath": config_data["url"], + "configUrl": config_url, + "studioUrl": studio_url, + "config": config_data, + } + ) diff --git a/src/static/js/mviewerstudio.js b/src/static/js/mviewerstudio.js index 02a4c0e0..a891c215 100644 --- a/src/static/js/mviewerstudio.js +++ b/src/static/js/mviewerstudio.js @@ -3,20 +3,36 @@ var API = {}; var mviewer = {}; -$(document).ready(function () { - //Get URL Parameters - if (window.location.search) { - $.extend( - API, - $.parseJSON( - '{"' + - decodeURIComponent( - window.location.search.substring(1).replace(/&/g, '","').replace(/=/g, '":"') - ) + - '"}' - ) - ); +/** + * Reads URL parameters from both the query string and the ``#?`` hash syntax. + * + * This keeps backward compatibility with the existing query-string behavior + * while allowing external applications to open Studio with + * `index.html#?config=`. + * + * @returns {Object.} Flat map of URL parameters. + */ +var readUrlParameters = function () { + const params = new URLSearchParams(window.location.search); + const hash = window.location.hash || ""; + const hashQuery = hash.startsWith("#?") ? hash.slice(2) : hash.startsWith("#") ? hash.slice(1) : ""; + + if (hashQuery) { + new URLSearchParams(hashQuery).forEach((value, key) => { + params.set(key, value); + }); } + + const parsedParams = {}; + params.forEach((value, key) => { + parsedParams[key] = value; + }); + + return parsedParams; +}; + +$(document).ready(function () { + API = readUrlParameters(); fetch("config.json", { method: "GET", header: { @@ -133,7 +149,9 @@ $(document).ready(function () { } } - if (API.xml) { + if (API.config) { + loadApplicationParametersFromRemoteFile(API.config); + } else if (API.xml) { loadApplicationParametersFromRemoteFile(API.xml); } else if (API.wmc) { loadApplicationParametersFromWMC(API.wmc); From 1295b282abaabeb9342e454075c335b2c9673ef4 Mon Sep 17 00:00:00 2001 From: gaetanbrl Date: Thu, 4 Jun 2026 16:53:05 +0200 Subject: [PATCH 2/3] qgis srv light manager and importer --- src/routes/qgis.py | 277 +++++++++++-- src/routes/shared.py | 66 ++-- src/static/css/mviewerstudio.css | 35 ++ src/static/index.html | 34 +- src/static/js/mviewerstudio.js | 9 +- src/static/js/utils/qgis/qgis.js | 449 +++++++++++++++++++--- src/static/js/utils/qgis/qgisToMviewer.js | 4 +- src/static/lib/mv.js | 12 + 8 files changed, 761 insertions(+), 125 deletions(-) diff --git a/src/routes/qgis.py b/src/routes/qgis.py index ea4daec1..c381a2b9 100644 --- a/src/routes/qgis.py +++ b/src/routes/qgis.py @@ -3,12 +3,14 @@ from uuid import uuid4 from tempfile import TemporaryDirectory from os import mkdir, path, walk +from shutil import rmtree from urllib.parse import quote import xml.etree.ElementTree as ET import requests from flask import current_app, jsonify, redirect, request from flask.typing import ResponseReturnValue +from werkzeug.exceptions import HTTPException from werkzeug.exceptions import BadRequest, MethodNotAllowed from werkzeug.utils import secure_filename @@ -91,10 +93,140 @@ def _mviewer_xml_from_capabilities_url(capabilities_url: str) -> str: with TemporaryDirectory(prefix="mviewerstudio-qgis-") as temp_dir: capabilities_path = Path(temp_dir) / "GetCapabilities.xml" capabilities_path.write_bytes(response.content) - return create_mviewer_xml_text_from_wms_capabilities( + xml_content = create_mviewer_xml_text_from_wms_capabilities( capabilities_path, service_base_url, ) + return _apply_root_layer_bbox_to_mviewer_xml(xml_content, response.content) + + +def _xml_local_name(tag: str) -> str: + """Return the local XML name without its optional namespace.""" + return tag.rsplit("}", 1)[-1] if "}" in tag else tag + + +def _find_direct_child(element: ET.Element, child_name: str) -> ET.Element | None: + """Return the first direct child matching the provided local name.""" + for child in list(element): + if _xml_local_name(child.tag) == child_name: + return child + return None + + +def _find_direct_children(element: ET.Element, child_name: str) -> list[ET.Element]: + """Return all direct children matching the provided local name.""" + return [child for child in list(element) if _xml_local_name(child.tag) == child_name] + + +def _format_bbox_value(value: float) -> str: + """Serialize a bbox numeric value without trailing zeros when possible.""" + return f"{value:.12g}" + + +def _parse_bbox_attributes(bbox_node: ET.Element) -> tuple[float, float, float, float] | None: + """Return bbox coordinates from a WMS ``BoundingBox``-like XML node.""" + try: + return ( + float(bbox_node.attrib["minx"]), + float(bbox_node.attrib["miny"]), + float(bbox_node.attrib["maxx"]), + float(bbox_node.attrib["maxy"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _root_layer_bbox_from_capabilities( + capabilities_content: bytes, target_projection: str | None +) -> str | None: + """Return the root-layer bbox matching the target projection when available.""" + try: + capabilities_root = ET.fromstring(capabilities_content) + except ET.ParseError: + return None + + capability_node = _find_direct_child(capabilities_root, "Capability") + root_layer_node = ( + _find_direct_child(capability_node, "Layer") if capability_node is not None else None + ) + if root_layer_node is None: + return None + + bounding_boxes = _find_direct_children(root_layer_node, "BoundingBox") + normalized_projection = (target_projection or "").upper() + + matching_bbox_node = None + if normalized_projection: + matching_bbox_node = next( + ( + bbox_node + for bbox_node in bounding_boxes + if ( + bbox_node.attrib.get("CRS") or bbox_node.attrib.get("SRS") or "" + ).upper() + == normalized_projection + ), + None, + ) + + if matching_bbox_node is None and len(bounding_boxes) == 1: + matching_bbox_node = bounding_boxes[0] + + bbox_values = ( + _parse_bbox_attributes(matching_bbox_node) if matching_bbox_node is not None else None + ) + if bbox_values is None and normalized_projection in ("", "EPSG:4326", "CRS:84"): + latlon_bbox_node = _find_direct_child(root_layer_node, "LatLonBoundingBox") + bbox_values = ( + _parse_bbox_attributes(latlon_bbox_node) if latlon_bbox_node is not None else None + ) + + if bbox_values is None: + geographic_bbox_node = _find_direct_child(root_layer_node, "EX_GeographicBoundingBox") + if geographic_bbox_node is not None: + west_node = _find_direct_child(geographic_bbox_node, "westBoundLongitude") + south_node = _find_direct_child(geographic_bbox_node, "southBoundLatitude") + east_node = _find_direct_child(geographic_bbox_node, "eastBoundLongitude") + north_node = _find_direct_child(geographic_bbox_node, "northBoundLatitude") + try: + bbox_values = ( + float(west_node.text), + float(south_node.text), + float(east_node.text), + float(north_node.text), + ) + except (AttributeError, TypeError, ValueError): + bbox_values = None + + if bbox_values is None: + return None + + return ",".join(_format_bbox_value(value) for value in bbox_values) + + +def _apply_root_layer_bbox_to_mviewer_xml( + xml_content: str, capabilities_content: bytes +) -> str: + """Inject the root-layer bbox into generated mviewer map options.""" + xml_root = ET.fromstring(xml_content) + mapoptions_node = xml_root.find("mapoptions") + if mapoptions_node is None: + return xml_content + + projection = mapoptions_node.get("projection") + bbox = _root_layer_bbox_from_capabilities(capabilities_content, projection) + if not bbox: + return xml_content + + minx, miny, maxx, maxy = [float(value) for value in bbox.split(",")] + center = ",".join( + _format_bbox_value(value) + for value in ((minx + maxx) / 2, (miny + maxy) / 2) + ) + + mapoptions_node.set("extent", bbox) + mapoptions_node.set("center", center) + return ET.tostring(xml_root, encoding="unicode") def _store_uploaded_qgis_project() -> dict: @@ -116,14 +248,18 @@ def _store_uploaded_qgis_project() -> dict: mkdir(qgs_dir) if lower_filename.endswith((".zip", ".qgz")): - _, extracted_projects = _extract_qgs_zip(uploaded_file, qgs_dir, filename) + _, extracted_projects, overwritten = _extract_qgs_zip( + uploaded_file, qgs_dir, filename + ) project_payload = extracted_projects[0].copy() project_payload["projects"] = extracted_projects project_payload["archiveName"] = filename + project_payload["overwritten"] = overwritten return project_payload - project_payload = _store_qgs_file(uploaded_file, qgs_dir, filename) + project_payload, overwritten = _store_qgs_file(uploaded_file, qgs_dir, filename) project_payload["projects"] = [project_payload.copy()] + project_payload["overwritten"] = overwritten return project_payload @@ -228,6 +364,46 @@ def _studio_open_config_url(config_url: str) -> str: return f"{base_url}/index.html#?config={quote(config_url, safe='/:?&=%')}" +def _stored_qgis_project_directory(project_name: str) -> str: + """Return the absolute directory of a stored QGIS project.""" + qgs_dir = current_app.config.get("QGS_FOLDER") + if not qgs_dir: + raise BadRequest("Missing QGS folder configuration") + if not project_name: + raise BadRequest("Missing QGIS project name") + + normalized_project_name = secure_filename(project_name) + project_dir = path.abspath(path.join(qgs_dir, normalized_project_name)) + qgs_dir_absolute = path.abspath(qgs_dir) + if path.commonpath([qgs_dir_absolute, project_dir]) != qgs_dir_absolute: + raise BadRequest("Invalid QGIS project name") + return project_dir + + +def _qgis_open_error_response( + error: Exception, project_name: str | None = None +) -> ResponseReturnValue: + """Build a JSON error response for QGIS project open/create routes.""" + if isinstance(error, HTTPException): + status_code = error.code or 500 + reason = error.description + error_name = error.name + else: + status_code = 500 + reason = str(error) or "Unexpected error while creating the QGIS configuration" + error_name = "Internal Server Error" + + payload = { + "success": False, + "name": error_name, + "reason": reason, + } + if project_name: + payload["projectName"] = project_name + + return jsonify(payload), status_code + + @basic_store.route("/api/app/qgis/projects", methods=["GET", "POST"]) def list_stored_qgs_configs() -> ResponseReturnValue: """List stored QGIS projects or upload a new project/archive.""" @@ -280,41 +456,88 @@ def import_qgis_project() -> ResponseReturnValue: mkdir(qgs_dir) if lower_filename.endswith((".zip", ".qgz")): - _, extracted_projects = _extract_qgs_zip(uploaded_file, qgs_dir, filename) + _, extracted_projects, overwritten = _extract_qgs_zip( + uploaded_file, qgs_dir, filename + ) project_payload = extracted_projects[0] else: - project_payload = _store_qgs_file(uploaded_file, qgs_dir, filename) + project_payload, overwritten = _store_qgs_file(uploaded_file, qgs_dir, filename) capabilities_url = _project_capabilities_url(project_payload["projectName"]) xml_content = _mviewer_xml_from_capabilities_url(capabilities_url) response = current_app.response_class(xml_content, mimetype="application/xml") response.headers["X-Qgis-Project-Name"] = project_payload["projectName"] response.headers["X-Qgis-Capabilities-Url"] = capabilities_url + response.headers["X-Qgis-Project-Overwritten"] = str(overwritten).lower() return response @basic_store.route("/api/app/qgis/projects/open", methods=["POST"]) def create_and_open_qgis_project_config() -> ResponseReturnValue: """Create a Studio config from an uploaded QGIS project and expose open URLs.""" - project_payload = _store_uploaded_qgis_project() - capabilities_url = _project_capabilities_url(project_payload["projectName"]) - config_data, _ = _create_config_from_qgis_capabilities( - capabilities_url, project_payload["projectName"] - ) - config_url = _absolute_config_url(config_data["url"]) - studio_url = _studio_open_config_url(config_url) - - if request.args.get("redirect", "").lower() in {"1", "true", "yes"}: - return redirect(studio_url) - - return jsonify( - { - "success": True, - "project": project_payload, - "capabilitiesUrl": capabilities_url, - "filepath": config_data["url"], - "configUrl": config_url, - "studioUrl": studio_url, - "config": config_data, - } - ) + project_name = None + try: + project_payload = _store_uploaded_qgis_project() + project_name = project_payload["projectName"] + capabilities_url = _project_capabilities_url(project_name) + config_data, _ = _create_config_from_qgis_capabilities( + capabilities_url, project_name + ) + config_url = _absolute_config_url(config_data["url"]) + studio_url = _studio_open_config_url(config_url) + + if request.args.get("redirect", "").lower() in {"1", "true", "yes"}: + return redirect(studio_url) + + return jsonify( + { + "success": True, + "project": project_payload, + "capabilitiesUrl": capabilities_url, + "filepath": config_data["url"], + "configUrl": config_url, + "studioUrl": studio_url, + "config": config_data, + } + ) + except Exception as error: + return _qgis_open_error_response(error, project_name) + + +@basic_store.route("/api/app/qgis/projects//open", methods=["POST"]) +def create_and_open_stored_qgis_project_config(project_name: str) -> ResponseReturnValue: + """Create a Studio config from an existing stored QGIS project.""" + try: + _stored_qgis_project_directory(project_name) + capabilities_url = _project_capabilities_url(project_name) + config_data, _ = _create_config_from_qgis_capabilities(capabilities_url, project_name) + config_url = _absolute_config_url(config_data["url"]) + studio_url = _studio_open_config_url(config_url) + + if request.args.get("redirect", "").lower() in {"1", "true", "yes"}: + return redirect(studio_url) + + return jsonify( + { + "success": True, + "projectName": project_name, + "capabilitiesUrl": capabilities_url, + "filepath": config_data["url"], + "configUrl": config_url, + "studioUrl": studio_url, + "config": config_data, + } + ) + except Exception as error: + return _qgis_open_error_response(error, project_name) + + +@basic_store.route("/api/app/qgis/projects/", methods=["DELETE"]) +def delete_stored_qgis_project(project_name: str) -> ResponseReturnValue: + """Delete a stored QGIS project directory.""" + project_dir = _stored_qgis_project_directory(project_name) + if not path.exists(project_dir): + raise BadRequest("QGIS project does not exist") + + rmtree(project_dir, ignore_errors=True) + return jsonify({"success": True, "projectName": project_name}) diff --git a/src/routes/shared.py b/src/routes/shared.py index d3f30cfc..86371908 100644 --- a/src/routes/shared.py +++ b/src/routes/shared.py @@ -4,7 +4,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from os import makedirs, mkdir, path, walk -from shutil import rmtree, copyfileobj, move +from shutil import rmtree, copyfileobj from typing import cast from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from zipfile import BadZipFile, ZipFile @@ -135,15 +135,10 @@ def _qgs_project_payload(qgs_dir: str, absolute_file: str) -> dict: def _extract_qgs_zip( uploaded_file, qgs_dir: str, filename: str -) -> tuple[str, list[dict]]: +) -> tuple[str, list[dict], bool]: archive_name = path.splitext(filename)[0] destination_dir = path.join(qgs_dir, archive_name) - - if path.exists(destination_dir): - rmtree(destination_dir, ignore_errors=True) - - mkdir(destination_dir) - + overwritten = False try: with ZipFile(uploaded_file.stream) as archive: file_members = [ @@ -161,6 +156,8 @@ def _extract_qgs_zip( if len(top_level_parts) == 1 and len(file_members) > 0: root_prefix = f"{top_level_parts.pop()}/" + normalized_members = [] + qgs_members = [] for member in file_members: normalized_name = member.filename if root_prefix and normalized_name.startswith(root_prefix): @@ -170,6 +167,25 @@ def _extract_qgs_zip( if not normalized_name: continue + normalized_members.append((member, normalized_name)) + if normalized_name.lower().endswith(".qgs"): + qgs_members.append(normalized_name) + + if not qgs_members: + raise BadRequest("ZIP archive does not contain any .qgs file") + + if len(qgs_members) == 1: + project_name = path.splitext(path.basename(qgs_members[0]))[0] + destination_dir = path.join(qgs_dir, project_name) + else: + destination_dir = path.join(qgs_dir, archive_name) + + overwritten = path.exists(destination_dir) + if overwritten: + rmtree(destination_dir, ignore_errors=True) + mkdir(destination_dir) + + for member, normalized_name in normalized_members: destination = path.abspath(path.join(destination_dir, normalized_name)) if path.commonpath( [path.abspath(destination_dir), destination] @@ -183,7 +199,8 @@ def _extract_qgs_zip( with archive.open(member) as source, open(destination, "wb") as target: copyfileobj(source, target) except BadZipFile: - rmtree(destination_dir, ignore_errors=True) + if path.exists(destination_dir): + rmtree(destination_dir, ignore_errors=True) raise BadRequest("Invalid ZIP archive") except Exception: if path.exists(destination_dir): @@ -196,40 +213,19 @@ def _extract_qgs_zip( if extracted_file.lower().endswith(".qgs"): extracted_projects.append(path.join(root, extracted_file)) - if not extracted_projects: - rmtree(destination_dir, ignore_errors=True) - raise BadRequest("ZIP archive does not contain any .qgs file") - - if len(extracted_projects) == 1: - project_file = extracted_projects[0] - project_name = path.splitext(path.basename(project_file))[0] - final_destination_dir = path.join(qgs_dir, project_name) - - if path.abspath(final_destination_dir) != path.abspath(destination_dir): - if path.exists(final_destination_dir): - rmtree(final_destination_dir, ignore_errors=True) - move(destination_dir, final_destination_dir) - destination_dir = final_destination_dir - - extracted_projects = [ - path.join(root, extracted_file) - for root, _, files in walk(destination_dir) - for extracted_file in files - if extracted_file.lower().endswith(".qgs") - ] - extracted_payloads = [ _qgs_project_payload(qgs_dir, absolute_file) for absolute_file in extracted_projects ] extracted_payloads.sort(key=lambda item: item["path"].lower()) - return destination_dir, extracted_payloads + return destination_dir, extracted_payloads, overwritten -def _store_qgs_file(uploaded_file, qgs_dir: str, filename: str) -> dict: +def _store_qgs_file(uploaded_file, qgs_dir: str, filename: str) -> tuple[dict, bool]: project_name = path.splitext(filename)[0] destination_dir = path.join(qgs_dir, project_name) + overwritten = path.exists(destination_dir) - if path.exists(destination_dir): + if overwritten: rmtree(destination_dir, ignore_errors=True) mkdir(destination_dir) @@ -241,7 +237,7 @@ def _store_qgs_file(uploaded_file, qgs_dir: str, filename: str) -> dict: rmtree(destination_dir, ignore_errors=True) raise - return _qgs_project_payload(qgs_dir, destination) + return _qgs_project_payload(qgs_dir, destination), overwritten @basic_store.record_once diff --git a/src/static/css/mviewerstudio.css b/src/static/css/mviewerstudio.css index 9e0948a6..3db32bb2 100644 --- a/src/static/css/mviewerstudio.css +++ b/src/static/css/mviewerstudio.css @@ -73,6 +73,41 @@ vertical-align: middle; margin-left: 3px; } + + .qgis-project-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + .qgis-project-action-btn { + width: 2.2rem; + height: 2.2rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.45rem; + } + + .qgis-project-action-btn i { + font-size: 1rem; + line-height: 1; + } + + .qgis-project-action-btn-copy { + color: #7c3aed; + border: 1px solid #7c3aed; + background: #ffffff; + } + + .qgis-project-action-btn-copy:hover, + .qgis-project-action-btn-copy:focus { + color: #ffffff; + background: #7c3aed; + border-color: #7c3aed; + } /* Alert */ diff --git a/src/static/index.html b/src/static/index.html index 18ba9274..03a104cf 100755 --- a/src/static/index.html +++ b/src/static/index.html @@ -1125,6 +1125,31 @@ + +
+
+ + +
+
+ + + + + + + + + + + + +
ProjetAction
Chargement…
+
+
@@ -1794,7 +1819,7 @@
Paramètres du projet QGIS
- +
@@ -1916,7 +1941,7 @@
Paramètres du projet QGIS
-
@@ -2090,9 +2115,8 @@ - - + diff --git a/src/static/js/mviewerstudio.js b/src/static/js/mviewerstudio.js index a891c215..270961ac 100644 --- a/src/static/js/mviewerstudio.js +++ b/src/static/js/mviewerstudio.js @@ -159,6 +159,8 @@ $(document).ready(function () { newConfiguration(); } + document.dispatchEvent(new CustomEvent("mviewerstudio:qgis:init-stored-projects")); + updateProviderSearchButtonState(); // Default params for layers @@ -1354,8 +1356,11 @@ var loadApplicationParametersFromFile = function () { return; } - if (window.qgis && typeof qgis.loadApplicationParametersFromFileInput === "function") { - qgis.loadApplicationParametersFromFileInput(); + const qgisImportEvent = new CustomEvent( + "mviewerstudio:qgis:load-application-parameters-from-file-input", + { cancelable: true } + ); + if (!document.dispatchEvent(qgisImportEvent)) { return; } diff --git a/src/static/js/utils/qgis/qgis.js b/src/static/js/utils/qgis/qgis.js index 7180db10..d6eb6ac2 100644 --- a/src/static/js/utils/qgis/qgis.js +++ b/src/static/js/utils/qgis/qgis.js @@ -1,5 +1,15 @@ +import qgisToMviewer from "./qgisToMviewer.js"; + +const escapeHtmlAttribute = function (value) { + return String(value || "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +}; + // QGIS project import utilities -var qgis = { +const qgis = { /** * Tracks whether the QGIS project search UI listeners have been registered. * @@ -18,6 +28,12 @@ var qgis = { * @type {boolean} */ _qgisLocalFileImportInitialized: false, + /** + * Tracks whether the stored QGIS projects list listeners have been registered. + * + * @type {boolean} + */ + _qgisProjectsListInitialized: false, /** * Imports a QGIS project file content. * @@ -25,15 +41,10 @@ var qgis = { * @returns {string|undefined} Generated mviewer XML, or `undefined` when the converter is unavailable. */ importQgs: function (content) { - if (typeof qgisToMviewer === "undefined") { - alertCustom("QGIS to mviewer converter is not loaded", "danger"); - return; - } - const mviewerXml = qgisToMviewer.convertQgsContentToMviewerXml(content); - if (typeof mv !== "undefined" && typeof mv.parseApplication === "function") { + if (typeof window.mv !== "undefined" && typeof window.mv.parseApplication === "function") { const xmlDoc = new DOMParser().parseFromString(mviewerXml, "text/xml"); - mv.parseApplication(xmlDoc); + window.mv.parseApplication(xmlDoc); } return mviewerXml; }, @@ -47,11 +58,13 @@ var qgis = { const idApp = xml.getElementsByTagName("dc:identifier")[0]?.innerHTML; if (!idApp) { - mv.parseApplication(xml); + window.mv.parseApplication(xml); return; } - mv.appExists(idApp, (response) => mv.parseApplication(xml, response.exists)); + window.mv.appExists(idApp, (response) => + window.mv.parseApplication(xml, response.exists) + ); }, /** * Returns `true` when the selected file is a QGIS project. @@ -65,8 +78,7 @@ var qgis = { return ( fileName.endsWith(".qgs") || fileName.endsWith(".qgis") || - (typeof qgisToMviewer !== "undefined" && - qgisToMviewer.isQgisProjectDocument(xml)) + qgisToMviewer.isQgisProjectDocument(xml) ); }, /** @@ -83,12 +95,9 @@ var qgis = { return file .text() .then((content) => { - const xml = $.parseXML(content); + const xml = window.$.parseXML(content); if (this.isQgisProjectFile(file, xml)) { - if (typeof qgisToMviewer === "undefined") { - throw new Error("QGIS to mviewer converter is not loaded"); - } - return $.parseXML(qgisToMviewer.convertQgsContentToMviewerXml(content)); + return window.$.parseXML(qgisToMviewer.convertQgsContentToMviewerXml(content)); } return xml; }); @@ -113,14 +122,10 @@ var qgis = { this.readImportFileAsMviewerXml(file) .then((xml) => { this.parseMviewerApplicationXml(xml); - showStudio(); + window.showStudio(); }) .catch((error) => { - if (error?.message === "QGIS to mviewer converter is not loaded") { - alertCustom(error.message, "danger"); - return; - } - alertCustom(mviewer.tr("msg.xml_doc_invalid"), "danger"); + window.alertCustom(window.mviewer.tr("msg.xml_doc_invalid"), "danger"); }); }, /** @@ -129,9 +134,36 @@ var qgis = { * @returns {string} Base URL ending with a slash when configured. */ getConfiguredQgisProjectsBaseUrl: function () { - const configuredUrl = _conf?.qgis?.url || ""; + const configuredUrl = window._conf?.qgis?.url || ""; return configuredUrl ? configuredUrl.replace(/\/?$/, "/") : ""; }, + /** + * Returns `true` when the configured QGIS Server host matches the current Studio host. + * + * Localhost aliases are treated as equivalent so that local dev setups keep + * showing the stored-projects table. + * + * @returns {boolean} `true` when the QGIS Server is hosted on the same machine. + */ + isConfiguredQgisServerOnCurrentHost: function () { + const configuredUrl = this.getConfiguredQgisProjectsBaseUrl(); + if (!configuredUrl) { + return false; + } + + const configuredHost = new URL(configuredUrl, window.location.href).hostname.toLowerCase(); + const currentHost = window.location.hostname.toLowerCase(); + const localhostAliases = ["localhost", "127.0.0.1", "::1"]; + + if (configuredHost === currentHost) { + return true; + } + + return ( + localhostAliases.includes(configuredHost) && + localhostAliases.includes(currentHost) + ); + }, /** * Builds the GetCapabilities URL for a QGIS project stored on the server. * @@ -221,15 +253,15 @@ var qgis = { return this.createMviewerXmlFromQgisUrl(projectUrl) .then((mviewerXml) => { - this.parseMviewerApplicationXml($.parseXML(mviewerXml)); - showStudio(); + this.parseMviewerApplicationXml(window.$.parseXML(mviewerXml)); + window.showStudio(); }) .catch((error) => { const message = typeof error === "string" ? error : error?.message || "La conversion du projet QGIS a échoué"; - alertCustom(message, "danger"); + window.alertCustom(message, "danger"); }) .finally(() => { if (button) { @@ -269,8 +301,8 @@ var qgis = { return this.createMviewerXmlFromQgisUrl(capabilitiesUrl); }) .then((mviewerXml) => { - this.parseMviewerApplicationXml($.parseXML(mviewerXml)); - showStudio(); + this.parseMviewerApplicationXml(window.$.parseXML(mviewerXml)); + window.showStudio(); }); }, /** @@ -297,11 +329,227 @@ var qgis = { : response.text().then((text) => Promise.reject(text)) ) .then((data) => { - alertCustom(`${data.fileName} uploaded`, "success"); + window.alertCustom(`${data.fileName} uploaded`, "success"); + this.refreshStoredQgisProjectsTable(); return data; }) .catch((error) => { - alertCustom(error || "QGS upload failed", "danger"); + window.alertCustom(error || "QGS upload failed", "danger"); + return Promise.reject(error); + }); + }, + /** + * Returns the list of QGIS projects currently stored on the server. + * + * @returns {Promise>} Stored QGIS projects metadata. + */ + fetchStoredQgisProjects: function () { + return fetch("api/app/qgis/projects", { + method: "GET", + cache: "no-cache", + }).then((response) => + response.ok + ? response.json() + : response.text().then((text) => Promise.reject(text || response.statusText)) + ); + }, + /** + * Renders the server-side QGIS projects table inside the import tab. + * + * @param {Array} projects Stored QGIS projects metadata. + * @returns {void} + */ + renderStoredQgisProjectsTable: function (projects) { + const tableBody = document.getElementById("qgis-projects-table-body"); + if (!tableBody) { + return; + } + + if (!projects || projects.length === 0) { + tableBody.innerHTML = + 'Aucune configuration QGIS disponible.'; + return; + } + + tableBody.innerHTML = projects + .map((project) => { + const projectName = project.projectName || ""; + const capabilitiesUrl = this.buildProjectCapabilitiesUrl(projectName); + return ` + + ${projectName} + +
+ + + + +
+ + + `; + }) + .join(""); + + if (typeof window._initTooltip === "function") { + window._initTooltip(false); + } + }, + /** + * Copies a QGIS project GetCapabilities URL to the clipboard. + * + * @param {string} url GetCapabilities URL to copy. + * @returns {Promise} Promise resolved when the URL has been copied. + */ + copyQgisProjectUrl: function (url) { + if (!url) { + return Promise.reject(new Error("Missing QGIS project URL")); + } + + return navigator.clipboard + .writeText(url) + .then(() => { + window.alertCustom("URL copiée dans le presse-papiers", "success"); + }) + .catch(() => { + window.alertCustom("Impossible de copier l'URL", "danger"); + }); + }, + /** + * Opens a GetCapabilities URL in a separate browser tab. + * + * @param {string} url GetCapabilities URL to open. + * @returns {void} + */ + viewStoredQgisProjectCapabilities: function (url) { + if (!url) { + window.alertCustom("URL QGIS manquante", "danger"); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }, + /** + * Creates a Studio config from a stored QGIS project and opens it in a new tab. + * + * @param {string} projectName Stored QGIS project name. + * @returns {Promise} Promise resolved when the config has been created. + */ + createStoredQgisProjectConfig: function (projectName) { + if (!projectName) { + return Promise.reject(new Error("Missing QGIS project name")); + } + + return fetch(`api/app/qgis/projects/${encodeURIComponent(projectName)}/open`, { + method: "POST", + }) + .then((response) => + response.ok + ? response.json() + : response.text().then((text) => Promise.reject(text || response.statusText)) + ) + .then((data) => { + if (data?.studioUrl) { + window.open(data.studioUrl, "_blank", "noopener,noreferrer"); + } + window.alertCustom("Configuration créée", "success"); + }) + .catch((error) => { + window.alertCustom(error || "La création de la configuration a échoué", "danger"); + return Promise.reject(error); + }); + }, + /** + * Deletes a stored QGIS project then refreshes the server-side table. + * + * @param {string} projectName Stored QGIS project name. + * @returns {Promise} Promise resolved when the project has been deleted. + */ + deleteStoredQgisProject: function (projectName) { + if (!projectName) { + return Promise.reject(new Error("Missing QGIS project name")); + } + if (!window.confirm(`Supprimer le projet QGIS "${projectName}" ?`)) { + return Promise.resolve(); + } + + return fetch(`api/app/qgis/projects/${encodeURIComponent(projectName)}`, { + method: "DELETE", + }) + .then((response) => + response.ok + ? response.json() + : response.text().then((text) => Promise.reject(text || response.statusText)) + ) + .then(() => { + window.alertCustom("Projet QGIS supprimé", "success"); + return this.refreshStoredQgisProjectsTable(); + }) + .catch((error) => { + window.alertCustom(error || "La suppression du projet QGIS a échoué", "danger"); + return Promise.reject(error); + }); + }, + /** + * Fetches and refreshes the QGIS projects table. + * + * @returns {Promise} Promise resolved when the table is up to date. + */ + refreshStoredQgisProjectsTable: function () { + const tableBody = document.getElementById("qgis-projects-table-body"); + if (tableBody) { + tableBody.innerHTML = + 'Chargement…'; + } + + return this.fetchStoredQgisProjects() + .then((projects) => { + this.renderStoredQgisProjectsTable(projects); + }) + .catch((error) => { + if (tableBody) { + tableBody.innerHTML = + 'Impossible de charger les configurations QGIS.'; + } return Promise.reject(error); }); }, @@ -346,10 +594,10 @@ var qgis = { callQgisServerProjectFromName: function (projectName) { return this.callQgisServerProjectFromNameWithUrl( projectName, - _conf.qgis_server_url || - _conf.qgis_server || - _conf.qgis_server_base_url || - _conf.data_providers?.wms?.[0]?.url + window._conf.qgis_server_url || + window._conf.qgis_server || + window._conf.qgis_server_base_url || + window._conf.data_providers?.wms?.[0]?.url ); }, /** @@ -362,10 +610,10 @@ var qgis = { callQgisServerProjectFromNameWithUrl: function (projectName, qgisServerUrl) { const serverUrl = qgisServerUrl || - _conf.qgis_server_url || - _conf.qgis_server || - _conf.qgis_server_base_url || - _conf.data_providers?.wms?.[0]?.url; + window._conf.qgis_server_url || + window._conf.qgis_server || + window._conf.qgis_server_base_url || + window._conf.data_providers?.wms?.[0]?.url; if (!projectName || !serverUrl) { return Promise.reject(new Error("Missing QGIS project name or server URL")); @@ -378,8 +626,8 @@ var qgis = { requestUrl.searchParams.set("MAP", projectName); const fetchUrl = - _conf.proxy && /^https?:\/\//.test(requestUrl.href) - ? `${_conf.proxy}${encodeURIComponent(requestUrl.href)}` + window._conf.proxy && /^https?:\/\//.test(requestUrl.href) + ? `${window._conf.proxy}${encodeURIComponent(requestUrl.href)}` : requestUrl.href; return fetch(fetchUrl, { @@ -417,8 +665,8 @@ var qgis = { requestUrl.searchParams.set("REQUEST", "GetCapabilities"); const fetchUrl = - _conf.proxy && /^https?:\/\//.test(requestUrl.href) - ? `${_conf.proxy}${encodeURIComponent(requestUrl.href)}` + window._conf.proxy && /^https?:\/\//.test(requestUrl.href) + ? `${window._conf.proxy}${encodeURIComponent(requestUrl.href)}` : requestUrl.href; return fetch(fetchUrl, { @@ -440,10 +688,10 @@ var qgis = { getQgisServerProjectCapabilities: function (fileName, qgisServerUrl) { const serverUrl = qgisServerUrl || - _conf.qgis_server_url || - _conf.qgis_server || - _conf.qgis_server_base_url || - _conf.data_providers?.wms?.[0]?.url; + window._conf.qgis_server_url || + window._conf.qgis_server || + window._conf.qgis_server_base_url || + window._conf.data_providers?.wms?.[0]?.url; if (!fileName || !serverUrl) { return Promise.reject(new Error("Missing QGIS project name or server URL")); @@ -468,8 +716,8 @@ var qgis = { requestUrl.searchParams.set("REQUEST", "GetCapabilities"); const fetchUrl = - _conf.proxy && /^https?:\/\//.test(requestUrl.href) - ? `${_conf.proxy}${encodeURIComponent(requestUrl.href)}` + window._conf.proxy && /^https?:\/\//.test(requestUrl.href) + ? `${window._conf.proxy}${encodeURIComponent(requestUrl.href)}` : requestUrl.href; return fetch(fetchUrl, { @@ -640,9 +888,9 @@ var qgis = { * @returns {void} */ showQgsProjectInputs: function () { - if (typeof mv !== "undefined") { - mv.resetSearch(); - mv.resetConfLayer(); + if (typeof window.mv !== "undefined") { + window.mv.resetSearch(); + window.mv.resetConfLayer(); } this.showQgsProjectTab(); @@ -656,6 +904,7 @@ var qgis = { initQgsProjectSearch: function () { const urlInput = document.getElementById("newlayer-qgs-url"); const button = document.getElementById("qgs_search_layers_btn"); + const select = document.getElementById("qgs-project-list"); if (!urlInput || !button) { return; } @@ -677,6 +926,17 @@ var qgis = { this.searchProjectLayers(); } }); + button.addEventListener("click", (event) => { + event.preventDefault(); + if (!button.disabled) { + this.searchProjectLayers(); + } + }); + if (select) { + select.addEventListener("change", (event) => { + selectQgsLayer(event.currentTarget); + }); + } syncButtonState(); this._qgsProjectSearchInitialized = true; @@ -759,12 +1019,76 @@ var qgis = { typeof error === "string" ? error : error?.message || "L'import du projet QGIS a échoué"; - alertCustom(message, "danger"); + window.alertCustom(message, "danger"); }); }); this._qgisLocalFileImportInitialized = true; }, + /** + * Registers listeners for the stored QGIS projects table and loads data once. + * + * @returns {void} + */ + initStoredQgisProjectsList: function () { + const container = document.getElementById("qgis-projects-list-container"); + const refreshButton = document.getElementById("refresh-qgis-projects-list"); + const loadQgisTab = document.querySelector('[data-bs-target="#loadQgis"]'); + const tableBody = document.getElementById("qgis-projects-table-body"); + + if (!container || !refreshButton || !loadQgisTab || !tableBody) { + return; + } + + if (!this.isConfiguredQgisServerOnCurrentHost()) { + container.classList.add("d-none"); + return; + } + + container.classList.remove("d-none"); + + if (this._qgisProjectsListInitialized) { + this.refreshStoredQgisProjectsTable(); + return; + } + + refreshButton.addEventListener("click", () => { + this.refreshStoredQgisProjectsTable().catch(() => {}); + }); + + loadQgisTab.addEventListener("shown.bs.tab", () => { + this.refreshStoredQgisProjectsTable().catch(() => {}); + }); + tableBody.addEventListener("click", (event) => { + const actionButton = event.target.closest("[data-qgis-action]"); + if (!actionButton) { + return; + } + + const action = actionButton.dataset.qgisAction; + const url = actionButton.dataset.qgisUrl; + const projectName = actionButton.dataset.qgisProjectName; + + if (action === "copy-url") { + this.copyQgisProjectUrl(url); + return; + } + if (action === "view-capabilities") { + this.viewStoredQgisProjectCapabilities(url); + return; + } + if (action === "create-config") { + this.createStoredQgisProjectConfig(projectName).catch(() => {}); + return; + } + if (action === "delete-project") { + this.deleteStoredQgisProject(projectName).catch(() => {}); + } + }); + + this.refreshStoredQgisProjectsTable().catch(() => {}); + this._qgisProjectsListInitialized = true; + }, }; /** @@ -773,7 +1097,7 @@ var qgis = { * @param {HTMLSelectElement} selectElement Layer select input. * @returns {void} */ -function selectQgsLayer(selectElement) { +const selectQgsLayer = function (selectElement) { const selectedOption = selectElement.options[selectElement.selectedIndex]; const layerName = selectedOption ? selectedOption.dataset.layerName || "" : ""; const layerTitle = selectedOption ? selectedOption.dataset.layerTitle || "" : ""; @@ -787,7 +1111,7 @@ function selectQgsLayer(selectElement) { if (nameInput) { nameInput.value = layerTitle || layerName; } -} +}; document.addEventListener("DOMContentLoaded", function () { const sendQgisProjectLink = document.getElementById("sendQgisProject"); @@ -808,3 +1132,18 @@ document.addEventListener("DOMContentLoaded", function () { }); } }); + +document.addEventListener("mviewerstudio:qgis:init-stored-projects", function () { + qgis.initStoredQgisProjectsList(); +}); + +document.addEventListener( + "mviewerstudio:qgis:load-application-parameters-from-file-input", + function (event) { + event.preventDefault(); + qgis.loadApplicationParametersFromFileInput(); + } +); + +export { selectQgsLayer }; +export default qgis; diff --git a/src/static/js/utils/qgis/qgisToMviewer.js b/src/static/js/utils/qgis/qgisToMviewer.js index eca619da..5780bb97 100644 --- a/src/static/js/utils/qgis/qgisToMviewer.js +++ b/src/static/js/utils/qgis/qgisToMviewer.js @@ -1,4 +1,4 @@ -var qgisToMviewer = (function () { +const qgisToMviewer = (function () { const DEFAULT_THEME_ID = "qgis"; const DEFAULT_THEME_NAME = "Projet QGIS"; @@ -625,3 +625,5 @@ var qgisToMviewer = (function () { }, }; })(); + +export default qgisToMviewer; diff --git a/src/static/lib/mv.js b/src/static/lib/mv.js index f6de9144..00fd97e0 100755 --- a/src/static/lib/mv.js +++ b/src/static/lib/mv.js @@ -1787,6 +1787,18 @@ var mv = (function () { }); map.getView().setCenter(mapoptions.attr("center").split(",").map(Number)); map.getView().setZoom(parseInt(mapoptions.attr("zoom"))); + if (mapoptions.attr("extent")) { + var extentValues = mapoptions + .attr("extent") + .split(",") + .map(Number) + .filter(function (value) { + return !Number.isNaN(value); + }); + if (extentValues.length === 4) { + map.getView().fit(extentValues, { size: map.getSize() }); + } + } //BaseLayers var baseLayersMode = $(xml).find("baselayers").attr("style") || "default"; $("#frm-bl-mode option[value='" + baseLayersMode + "']").prop("selected", true); From c281f0d560236f4ee43bb0244bac82ed96cde966 Mon Sep 17 00:00:00 2001 From: gaetanbrl Date: Thu, 4 Jun 2026 17:41:32 +0200 Subject: [PATCH 3/3] prettier --- src/static/js/mviewerstudio.js | 6 +++- src/static/js/utils/qgis/qgis.js | 44 ++++++++++++----------- src/static/js/utils/qgis/qgisToMviewer.js | 15 +++----- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/static/js/mviewerstudio.js b/src/static/js/mviewerstudio.js index 270961ac..8dbdbc03 100644 --- a/src/static/js/mviewerstudio.js +++ b/src/static/js/mviewerstudio.js @@ -15,7 +15,11 @@ var mviewer = {}; var readUrlParameters = function () { const params = new URLSearchParams(window.location.search); const hash = window.location.hash || ""; - const hashQuery = hash.startsWith("#?") ? hash.slice(2) : hash.startsWith("#") ? hash.slice(1) : ""; + const hashQuery = hash.startsWith("#?") + ? hash.slice(2) + : hash.startsWith("#") + ? hash.slice(1) + : ""; if (hashQuery) { new URLSearchParams(hashQuery).forEach((value, key) => { diff --git a/src/static/js/utils/qgis/qgis.js b/src/static/js/utils/qgis/qgis.js index d6eb6ac2..1807ee05 100644 --- a/src/static/js/utils/qgis/qgis.js +++ b/src/static/js/utils/qgis/qgis.js @@ -42,7 +42,10 @@ const qgis = { */ importQgs: function (content) { const mviewerXml = qgisToMviewer.convertQgsContentToMviewerXml(content); - if (typeof window.mv !== "undefined" && typeof window.mv.parseApplication === "function") { + if ( + typeof window.mv !== "undefined" && + typeof window.mv.parseApplication === "function" + ) { const xmlDoc = new DOMParser().parseFromString(mviewerXml, "text/xml"); window.mv.parseApplication(xmlDoc); } @@ -92,15 +95,13 @@ const qgis = { return Promise.reject(new Error("Missing import file")); } - return file - .text() - .then((content) => { - const xml = window.$.parseXML(content); - if (this.isQgisProjectFile(file, xml)) { - return window.$.parseXML(qgisToMviewer.convertQgsContentToMviewerXml(content)); - } - return xml; - }); + return file.text().then((content) => { + const xml = window.$.parseXML(content); + if (this.isQgisProjectFile(file, xml)) { + return window.$.parseXML(qgisToMviewer.convertQgsContentToMviewerXml(content)); + } + return xml; + }); }, /** * Loads the file currently selected in the main import input. @@ -151,7 +152,10 @@ const qgis = { return false; } - const configuredHost = new URL(configuredUrl, window.location.href).hostname.toLowerCase(); + const configuredHost = new URL( + configuredUrl, + window.location.href + ).hostname.toLowerCase(); const currentHost = window.location.hostname.toLowerCase(); const localhostAliases = ["localhost", "127.0.0.1", "::1"]; @@ -160,8 +164,7 @@ const qgis = { } return ( - localhostAliases.includes(configuredHost) && - localhostAliases.includes(currentHost) + localhostAliases.includes(configuredHost) && localhostAliases.includes(currentHost) ); }, /** @@ -191,7 +194,8 @@ const qgis = { */ isGetCapabilitiesUrl: function (projectUrl) { const url = new URL(projectUrl, window.location.href); - const requestValue = url.searchParams.get("REQUEST") || url.searchParams.get("request"); + const requestValue = + url.searchParams.get("REQUEST") || url.searchParams.get("request"); return (requestValue || "").toLowerCase() === "getcapabilities"; }, /** @@ -289,8 +293,7 @@ const qgis = { return this.sendQgisProject(file) .then((data) => { - const projectName = - data?.projectName || data?.projects?.[0]?.projectName || ""; + const projectName = data?.projectName || data?.projects?.[0]?.projectName || ""; const capabilitiesUrl = this.buildProjectCapabilitiesUrl(projectName); if (urlInput) { @@ -855,9 +858,7 @@ const qgis = { * @returns {void} */ showQgsProjectTab: function () { - const qgsProjectTab = document.querySelector( - '[data-bs-target="#newlayer-qgs"]' - ); + const qgsProjectTab = document.querySelector('[data-bs-target="#newlayer-qgs"]'); const qgsProjectPane = document.getElementById("newlayer-qgs"); if (qgsProjectTab) { @@ -957,7 +958,10 @@ const qgis = { const syncButtonState = () => { const hasValue = Boolean(urlInput.value.trim()); button.disabled = !hasValue; - urlInput.classList.toggle("is-invalid", !hasValue && document.activeElement !== urlInput); + urlInput.classList.toggle( + "is-invalid", + !hasValue && document.activeElement !== urlInput + ); }; if (this._qgisImportUrlInitialized) { diff --git a/src/static/js/utils/qgis/qgisToMviewer.js b/src/static/js/utils/qgis/qgisToMviewer.js index 5780bb97..ebc335b9 100644 --- a/src/static/js/utils/qgis/qgisToMviewer.js +++ b/src/static/js/utils/qgis/qgisToMviewer.js @@ -166,10 +166,7 @@ const qgisToMviewer = (function () { const title = text(mapLayer, "layername") || id; const cleanDatasource = datasource.split("|")[0]; - if ( - provider !== "ogr" || - !/\.(geojson|json)(\?|#|$)/i.test(cleanDatasource) - ) { + if (provider !== "ogr" || !/\.(geojson|json)(\?|#|$)/i.test(cleanDatasource)) { return null; } @@ -182,7 +179,7 @@ const qgisToMviewer = (function () { queryable: "true", searchable: "false", opacity: attr(mapLayer, "opacity", "1"), - }) + }); return { id: slugify(title, slugify(id, "qgis_geojson")), @@ -439,11 +436,7 @@ const qgisToMviewer = (function () { if (rootLayer) { collectNamedLayers(rootLayer).forEach((layerNode, index) => { - const layer = capabilitiesLayerToMviewerLayer( - layerNode, - serviceUrl, - index === 0 - ); + const layer = capabilitiesLayerToMviewerLayer(layerNode, serviceUrl, index === 0); if (layer) { layers.push(layer); } @@ -511,7 +504,7 @@ const qgisToMviewer = (function () { return [ '', - '', + "", padding(4) + ``, padding(4) +