From 0c6fcdd4f9a0d659ede4ddabbed5e967d43eba59 Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Fri, 31 Jul 2026 15:44:45 +0300 Subject: [PATCH 1/3] fix(import): preserve QGIS source semantics --- .../src/lib/qgis-project-import.ts | 28 +++++++-- tests/qgis-project-import.test.ts | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index 09ad9f790..28ff3d55d 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -405,13 +405,23 @@ function parseLayerGroups(document: Document): { id, name, collapsed: element.getAttribute("expanded") === "0", - visible: element.getAttribute("checked") !== "Qt::Unchecked", + visible: qgisGroupVisible(element, root), opacity: 1, }; }); return { groups, ids }; } +function qgisGroupVisible(element: Element, root: Element): boolean { + let current: Element | null = element; + while (current) { + if (current.getAttribute("checked") === "Qt::Unchecked") return false; + if (current === root) break; + current = current.parentElement?.closest("layer-tree-group") ?? null; + } + return true; +} + function groupDisplayName(element: Element, root: Element): string { const names: string[] = []; let current: Element | null = element; @@ -458,16 +468,22 @@ function qgisSourcePath(dataSource: string, projectPath: string): string { let source = dataSource.split("|", 1)[0]?.trim() ?? ""; source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); if (/^\/?vsizip\//i.test(source)) return ""; - if (source.startsWith("file://")) { + source = source.replace(/^['"]|['"]$/g, ""); + if (/^file:\/\//i.test(source)) { try { - source = decodeURIComponent(new URL(source).pathname).replace(/^\/([A-Za-z]:)/, "$1"); + const url = new URL(source); + const path = decodeURIComponent(url.pathname).replace(/^\/([A-Za-z]:)/, "$1"); + source = + url.hostname && url.hostname.toLowerCase() !== "localhost" + ? `//${url.hostname}${path}` + : path; } catch { - source = source.slice("file://".length); + const path = source.replace(/^file:\/\//i, ""); + source = path.startsWith("/") ? path : `//${path}`; } } source = source.replace(/^file:(?:\/\/)?/i, ""); - source = source.replace(/[?#].*$/, ""); - source = source.replace(/^['"]|['"]$/g, ""); + if (!isHttpSource(source)) source = source.replace(/[?#].*$/, ""); if (!source || isAbsolutePath(source) || /^[a-z]+:\/\//i.test(source)) return source; const directory = /[/\\]/.test(projectPath) ? projectPath.replace(/[/\\][^/\\]*$/, "") : ""; return normalizeJoinedPath(directory, source); diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 2779b5aef..36e608189 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -236,6 +236,64 @@ describe("QGIS project import", () => { ); }); + it("preserves remote URL credentials when materializing GeoJSON", async () => { + const remote = importQgisProject( + projectXml({ + dataSources: [ + { + id: "roads", + name: "Remote", + source: + "/vsicurl/https://example.com/roads.geojson?token=secret&version=2|layername=roads", + }, + ], + }), + "/work/example.qgs", + ); + const requested: string[] = []; + await materializeQgisRemoteLayers(remote, async (input) => { + requested.push(String(input)); + return Response.json({ type: "FeatureCollection", features: [] }); + }); + + assert.deepEqual(requested, ["https://example.com/roads.geojson?token=secret&version=2"]); + }); + + it("rejects UNC file URLs", () => { + const unc = importQgisProject( + projectXml({ + dataSources: [ + { + id: "cities", + name: "Network file URL", + source: "file://server/share/cities.gpkg", + }, + ], + }), + "C:\\projects\\example.qgs", + ); + assert.deepEqual( + unc.warnings.map((warning) => [warning.layerName, warning.reason]), + [["Network file URL", "network-path"]], + ); + }); + + it("inherits visibility from unchecked parent groups", () => { + const xml = projectXml().replace( + 'name="Transport" checked="Qt::Checked"', + 'name="Transport" checked="Qt::Unchecked"', + ); + const result = importQgisProject(xml, "/work/example.qgs"); + + assert.deepEqual( + result.project.layerGroups?.map((group) => [group.name, group.visible]), + [ + ["Transport", false], + ["Transport / Places", false], + ], + ); + }); + it("normalizes Windows file URLs, query strings, and bare project names", () => { const windows = importQgisProject( projectXml({ From 24ca49fc73663295e74d40b34e5ce3984dbbf750 Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Fri, 31 Jul 2026 16:41:15 +0300 Subject: [PATCH 2/3] test(import): cover quoted VSI and UNC sources --- apps/geolibre-desktop/src/lib/qgis-project-import.ts | 2 +- tests/qgis-project-import.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index 28ff3d55d..bce174246 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -466,9 +466,9 @@ function layerOrder(document: Document, mapLayers: Element[]): string[] { function qgisSourcePath(dataSource: string, projectPath: string): string { let source = dataSource.split("|", 1)[0]?.trim() ?? ""; + source = source.replace(/^['"]|['"]$/g, ""); source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); if (/^\/?vsizip\//i.test(source)) return ""; - source = source.replace(/^['"]|['"]$/g, ""); if (/^file:\/\//i.test(source)) { try { const url = new URL(source); diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 36e608189..630ba571c 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -244,7 +244,7 @@ describe("QGIS project import", () => { id: "roads", name: "Remote", source: - "/vsicurl/https://example.com/roads.geojson?token=secret&version=2|layername=roads", + '"/vsicurl/https://example.com/roads.geojson?token=secret&version=2"|layername=roads', }, ], }), @@ -272,6 +272,7 @@ describe("QGIS project import", () => { }), "C:\\projects\\example.qgs", ); + assert.deepEqual(unc.project.layers, []); assert.deepEqual( unc.warnings.map((warning) => [warning.layerName, warning.reason]), [["Network file URL", "network-path"]], From dc4a3895cbfb63ff760f5410e3133873a0d9c08d Mon Sep 17 00:00:00 2001 From: giswqs Date: Fri, 31 Jul 2026 18:13:09 -0400 Subject: [PATCH 3/3] fix(import): address QGIS source review feedback --- .../src/lib/qgis-project-import.ts | 9 ++++--- tests/qgis-project-import.test.ts | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index bce174246..b069c9bef 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -466,6 +466,7 @@ function layerOrder(document: Document, mapLayers: Element[]): string[] { function qgisSourcePath(dataSource: string, projectPath: string): string { let source = dataSource.split("|", 1)[0]?.trim() ?? ""; + let parsedFileUrl = false; source = source.replace(/^['"]|['"]$/g, ""); source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); if (/^\/?vsizip\//i.test(source)) return ""; @@ -473,24 +474,26 @@ function qgisSourcePath(dataSource: string, projectPath: string): string { try { const url = new URL(source); const path = decodeURIComponent(url.pathname).replace(/^\/([A-Za-z]:)/, "$1"); + parsedFileUrl = true; source = url.hostname && url.hostname.toLowerCase() !== "localhost" ? `//${url.hostname}${path}` : path; } catch { const path = source.replace(/^file:\/\//i, ""); - source = path.startsWith("/") ? path : `//${path}`; + source = path.startsWith("/") || /^[A-Za-z]:[/\\]/.test(path) ? path : `//${path}`; } } source = source.replace(/^file:(?:\/\/)?/i, ""); - if (!isHttpSource(source)) source = source.replace(/[?#].*$/, ""); + if (!parsedFileUrl && !isHttpSource(source)) source = source.replace(/[?#].*$/, ""); if (!source || isAbsolutePath(source) || /^[a-z]+:\/\//i.test(source)) return source; const directory = /[/\\]/.test(projectPath) ? projectPath.replace(/[/\\][^/\\]*$/, "") : ""; return normalizeJoinedPath(directory, source); } function sourceExtension(source: string): string { - return source.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase() ?? ""; + const path = isHttpSource(source) ? source.split(/[?#]/, 1)[0] : source; + return path?.split(".").pop()?.toLowerCase() ?? ""; } function normalizeJoinedPath(directory: string, relative: string): string { diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 630ba571c..598292a4b 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { applyGroupEffects } from "@geolibre/core"; import { strToU8, zipSync } from "fflate"; import { DOMParser } from "linkedom"; import { @@ -293,9 +294,11 @@ describe("QGIS project import", () => { ["Transport / Places", false], ], ); + const rendered = applyGroupEffects(result.project.layers, result.project.layerGroups ?? []); + assert.equal(rendered.find((layer) => layer.name === "Cities")?.visible, false); }); - it("normalizes Windows file URLs, query strings, and bare project names", () => { + it("normalizes Windows file URLs, query strings, encoded delimiters, and bare names", () => { const windows = importQgisProject( projectXml({ dataSources: [ @@ -310,6 +313,26 @@ describe("QGIS project import", () => { ); assert.equal(windows.project.layers[0].sourcePath, "C:/data/points.csv"); + const encoded = importQgisProject( + projectXml({ + dataSources: [ + { id: "roads", name: "Encoded delimiter", source: "file:///tmp/a%23b.geojson" }, + ], + }), + "/work/example.qgs", + ); + assert.equal(encoded.project.layers[0].sourcePath, "/tmp/a#b.geojson"); + + const malformed = importQgisProject( + projectXml({ + dataSources: [ + { id: "roads", name: "Malformed escape", source: "file://C:/data%zz/roads.geojson" }, + ], + }), + "C:\\projects\\example.qgs", + ); + assert.equal(malformed.project.layers[0].sourcePath, "C:/data%zz/roads.geojson"); + const browser = importQgisProject( projectXml({ dataSources: [{ id: "roads", name: "Roads", source: "data/roads.geojson" }],