From d3dc68ebe6c71fb2b97611e5c2d7b9837dc36257 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 29 Jul 2026 23:18:46 -0400 Subject: [PATCH 01/19] feat: import QGIS project files --- .../src/components/layout/TopToolbar.tsx | 7 + .../components/layout/toolbar/ImportMenu.tsx | 45 +++ .../layout/toolbar/ProjectFileDialogs.tsx | 36 ++ .../src/hooks/useProjectFileActions.ts | 34 ++ .../geolibre-desktop/src/i18n/locales/en.json | 15 + .../src/lib/qgis-project-import.ts | 375 ++++++++++++++++++ apps/geolibre-desktop/src/lib/tauri-io.ts | 14 + apps/geolibre-desktop/src/lib/ui-profile.ts | 2 + 8 files changed, 528 insertions(+) create mode 100644 apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx create mode 100644 apps/geolibre-desktop/src/lib/qgis-project-import.ts diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 4dc272558..675e2e9f5 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -123,6 +123,7 @@ import { ControlsMenu } from "./toolbar/ControlsMenu"; import { EditMenu } from "./toolbar/EditMenu"; import { ViewMenu } from "./toolbar/ViewMenu"; import { HelpMenu } from "./toolbar/HelpMenu"; +import { ImportMenu } from "./toolbar/ImportMenu"; import { OsmPbfDialogs } from "./toolbar/OsmPbfDialogs"; import { PluginsMenu } from "./toolbar/PluginsMenu"; import { PluginToolbarMenus } from "./toolbar/PluginToolbarMenus"; @@ -1440,6 +1441,12 @@ export function TopToolbar({ onOpenOfflineBasemap={onOpenBasemapExtract} /> )} + {isMenuVisible(uiProfile, "import") && ( + void projectFiles.handleImportQgisProject()} + /> + )} {isMenuVisible(uiProfile, "edit") && ( )} diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx new file mode 100644 index 000000000..e4a6ed69e --- /dev/null +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx @@ -0,0 +1,45 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@geolibre/ui"; +import { FileInput, Import } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ToolbarChrome } from "./constants"; + +interface ImportMenuProps { + chrome: ToolbarChrome; + onImportQgisProject: () => void; +} + +/** Import workflows that translate third-party project formats into GeoLibre. */ +export function ImportMenu({ chrome, onImportQgisProject }: ImportMenuProps) { + const { t } = useTranslation(); + return ( + + + + + + {t("toolbar.menu.import")} + + + + {t("toolbar.item.importQgisProjectEllipsis")} + + + + ); +} diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index 82c03c42c..abde58b05 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx @@ -94,6 +94,42 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { + { + if (!open) projectFiles.setQgisImportWarnings(null); + }} + > + + + {t("toolbar.item.qgisImportComplete")} + + {projectFiles.qgisImportWarnings?.length + ? t("toolbar.item.qgisImportWarnings", { + count: projectFiles.qgisImportWarnings.length, + }) + : t("toolbar.item.qgisImportSuccess")} + + + {projectFiles.qgisImportWarnings?.length ? ( +
    + {projectFiles.qgisImportWarnings.map((warning, index) => ( +
  • + {warning.layerName}:{" "} + {t(`toolbar.item.qgisImportReason.${warning.reason}`, { + provider: warning.provider || t("toolbar.item.qgisUnknownProvider"), + })} +
  • + ))} +
+ ) : null} +
+ +
+
+
{ diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 6679c3201..54a224c6b 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -18,6 +18,7 @@ import { isHttpUrl, isTauri, openProjectFile, + openQgisProjectFile, openRecentProjectFile, RecentProjectGoneError, saveProjectFile, @@ -33,6 +34,7 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre"; import { shareAuthorizedFetch } from "../lib/share-gallery"; import { normalizeProjectUrl } from "../lib/urls"; import { resolveProjectXyzLayers } from "../lib/xyz-url"; +import { importQgisProject, type QgisProjectImportWarning } from "../lib/qgis-project-import"; import type { MapControllerRef } from "../components/layout/toolbar/constants"; /** A pending "strip env vars before saving?" prompt. */ @@ -116,6 +118,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const markSaved = useAppStore((s) => s.markSaved); const [actionError, setActionError] = useState(null); + const [qgisImportWarnings, setQgisImportWarnings] = useState( + null, + ); const [projectUrlDialogOpen, setProjectUrlDialogOpen] = useState(false); const [projectUrl, setProjectUrl] = useState(""); const [projectUrlError, setProjectUrlError] = useState(null); @@ -152,6 +157,32 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { } }; + const handleImportQgisProject = async () => { + const result = await openQgisProjectFile(); + if (!result) return; + try { + const imported = importQgisProject(result.data, result.path); + if (!isTauri()) { + for (const layer of imported.project.layers) { + if (layer.sourcePath && !isHttpUrl(layer.sourcePath)) { + imported.warnings.push({ + layerName: layer.name, + reason: "browser-local-file", + }); + } + } + } + loadProject(imported.project, null); + useAppStore.setState({ isDirty: true }); + setQgisImportWarnings(imported.warnings); + } catch (error) { + console.error("Failed to import QGIS project", error); + setActionError( + error instanceof Error ? error.message : t("toolbar.error.couldNotImportQgisProject"), + ); + } + }; + const handleOpenFromUrl = async (event: FormEvent) => { event.preventDefault(); const normalizedUrl = normalizeProjectUrl(projectUrl); @@ -703,6 +734,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { return { actionError, setActionError, + qgisImportWarnings, + setQgisImportWarnings, projectUrlDialogOpen, setProjectUrlDialogOpen, handleProjectUrlDialogOpenChange, @@ -725,6 +758,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { submitSaveNamePrompt, cancelSaveNamePrompt, handleOpenFromFile, + handleImportQgisProject, handleOpenFromUrl, openProjectFromShareUrl, handleOpenRecent, diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 742b4a04d..c72a61b5d 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1825,6 +1825,7 @@ "toolbar": { "menu": { "project": "Project", + "import": "Import", "edit": "Edit", "view": "View", "addData": "Add Data", @@ -2396,6 +2397,19 @@ "webServices": "Web Services", "commandPalette": "Command Palette", "openProjectFromUrl": "Open project from URL", + "importQgisProjectEllipsis": "Import QGIS Project…", + "qgisImportComplete": "QGIS project imported", + "qgisImportSuccess": "All supported project content was imported.", + "qgisImportWarnings_one": "The project was imported, but {{count}} layer could not be loaded.", + "qgisImportWarnings_other": "The project was imported, but {{count}} layers could not be loaded.", + "qgisUnknownProvider": "unknown", + "qgisImportReason": { + "non-vector": "Only file-based vector layers are supported in this first version.", + "provider": "The {{provider}} data provider is not supported.", + "missing-source": "The layer has no readable data source.", + "format": "The layer's file format is not supported.", + "browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it." + }, "openProjectFromUrlDesc": "Load a `.geolibre.json` project and add it to recent projects.", "projectUrl": "Project URL", "opening": "Opening...", @@ -2505,6 +2519,7 @@ }, "error": { "couldNotOpenProject": "Could not open project.", + "couldNotImportQgisProject": "Could not import QGIS project.", "invalidProjectUrl": "Enter a valid HTTP or HTTPS project URL.", "couldNotOpenProjectUrl": "Could not open project URL.", "couldNotOpenRecentProject": "Could not open the recent project.", diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts new file mode 100644 index 000000000..279d35c37 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -0,0 +1,375 @@ +import { + DEFAULT_LAYER_STYLE, + createEmptyProject, + type GeoLibreLayer, + type GeoLibreProject, + type LayerGroup, + type LayerStyle, + type MapViewState, +} from "@geolibre/core"; +import { strFromU8, unzipSync } from "fflate"; + +export interface QgisProjectImportWarning { + layerName: string; + reason: "non-vector" | "provider" | "missing-source" | "format" | "browser-local-file"; + provider?: string; +} + +export interface QgisProjectImportResult { + project: GeoLibreProject; + warnings: QgisProjectImportWarning[]; +} + +const SUPPORTED_VECTOR_EXTENSIONS = new Set([ + "csv", + "dxf", + "fgb", + "flatgeobuf", + "geojson", + "geoparquet", + "gml", + "gpkg", + "gpx", + "json", + "kml", + "kmz", + "parquet", + "shp", + "tab", + "tsv", + "zip", +]); + +/** Convert a QGIS project into a GeoLibre project without evaluating QGIS code. */ +export function importQgisProject( + data: ArrayBuffer | Uint8Array | string, + sourcePath: string, +): QgisProjectImportResult { + const xml = qgisProjectXml(data, sourcePath); + const document = new DOMParser().parseFromString(xml, "application/xml"); + if (document.querySelector("parsererror") || document.documentElement.tagName !== "qgis") { + throw new Error("This file is not a valid QGIS project."); + } + + const projectName = + text(document.querySelector("title")) || fileStem(sourcePath) || "Imported QGIS Project"; + const project = createEmptyProject(projectName, { mapView: parseMapView(document) }); + const groups = parseLayerGroups(document); + const groupByLayerId = layerGroupAssignments(document, groups); + const visibilityByLayerId = layerVisibility(document); + const mapLayers = Array.from(document.querySelectorAll("projectlayers > maplayer")); + const byId = new Map( + mapLayers.map((element) => [text(element.querySelector(":scope > id")), element]), + ); + const warnings: QgisProjectImportWarning[] = []; + const layers: GeoLibreLayer[] = []; + + for (const id of layerOrder(document, mapLayers)) { + const element = byId.get(id); + if (!element) continue; + const name = text(element.querySelector(":scope > layername")) || id || "QGIS layer"; + const provider = text(element.querySelector(":scope > provider")).toLowerCase(); + const source = qgisVectorSource(text(element.querySelector(":scope > datasource")), sourcePath); + + if (!isSupportedVectorLayer(element, provider, source)) { + warnings.push({ + layerName: name, + reason: unsupportedReason(element, provider, source), + ...(provider ? { provider } : {}), + }); + continue; + } + + layers.push({ + id: uniqueLayerId(id, layers), + name, + type: "geojson", + source: { type: "geojson" }, + visible: visibilityByLayerId.get(id) ?? true, + opacity: parseOpacity(element), + style: parseLayerStyle(element), + metadata: { + localFileReloadable: true, + importedFrom: "qgis", + qgisLayerId: id, + qgisProvider: provider, + }, + sourcePath: source, + ...(groupByLayerId.get(id) ? { groupId: groupByLayerId.get(id) } : {}), + }); + } + + project.layers = layers; + project.layerGroups = groups.filter((group) => + layers.some((layer) => layer.groupId === group.id), + ); + project.metadata = { + importedFrom: "qgis", + qgisProjectPath: sourcePath, + qgisVersion: document.documentElement.getAttribute("version") ?? "", + }; + return { project, warnings }; +} + +function qgisProjectXml(data: ArrayBuffer | Uint8Array | string, sourcePath: string): string { + if (typeof data === "string") return data; + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + if (sourcePath.toLowerCase().endsWith(".qgs")) return strFromU8(bytes); + let entries: Record; + try { + entries = unzipSync(bytes); + } catch { + throw new Error("Could not read the compressed QGIS project."); + } + const qgsName = Object.keys(entries).find((name) => name.toLowerCase().endsWith(".qgs")); + if (!qgsName) throw new Error("The QGZ archive does not contain a QGS project file."); + return strFromU8(entries[qgsName]); +} + +function parseMapView(document: Document): MapViewState { + const extent = + document.querySelector("mapcanvas > extent") ?? + document.querySelector("projectviewsettings extent"); + const xmin = numberText(extent?.querySelector("xmin")); + const ymin = numberText(extent?.querySelector("ymin")); + const xmax = numberText(extent?.querySelector("xmax")); + const ymax = numberText(extent?.querySelector("ymax")); + const authId = + text(document.querySelector("mapcanvas > destinationsrs authid")) || + text(document.querySelector("projectCrs authid")); + if ([xmin, ymin, xmax, ymax].every(Number.isFinite)) { + const [west, south] = toWgs84(xmin, ymin, authId); + const [east, north] = toWgs84(xmax, ymax, authId); + if ([west, south, east, north].every(Number.isFinite)) { + return { + center: [(west + east) / 2, (south + north) / 2], + zoom: zoomForBounds(west, south, east, north), + bearing: 0, + pitch: 0, + bbox: [west, south, east, north], + }; + } + } + return { center: [-100, 40], zoom: 2, bearing: 0, pitch: 0 }; +} + +function toWgs84(x: number, y: number, authId: string): [number, number] { + if (authId.toUpperCase() !== "EPSG:3857") return [x, y]; + return [ + (x / 20037508.34) * 180, + (180 / Math.PI) * (2 * Math.atan(Math.exp((y / 20037508.34) * Math.PI)) - Math.PI / 2), + ]; +} + +function zoomForBounds(west: number, south: number, east: number, north: number): number { + const span = Math.max(Math.abs(east - west), Math.abs(north - south) * 2, 0.000001); + return Math.max(0, Math.min(20, Math.log2(360 / span) - 0.75)); +} + +function parseLayerGroups(document: Document): LayerGroup[] { + const root = document.querySelector("layer-tree-group"); + if (!root) return []; + return Array.from(root.children) + .filter((element) => element.tagName === "layer-tree-group") + .map((element, index) => ({ + id: `qgis-group-${index}-${slug(element.getAttribute("name") ?? "group")}`, + name: element.getAttribute("name") || "Group", + collapsed: element.getAttribute("expanded") === "0", + visible: element.getAttribute("checked") !== "Qt::Unchecked", + opacity: 1, + })); +} + +function layerGroupAssignments(document: Document, groups: LayerGroup[]): Map { + const assignments = new Map(); + const root = document.querySelector("layer-tree-group"); + if (!root) return assignments; + Array.from(root.children) + .filter((element) => element.tagName === "layer-tree-group") + .forEach((element, index) => { + const group = groups[index]; + if (!group) return; + element.querySelectorAll("layer-tree-layer[id]").forEach((layer) => { + const id = layer.getAttribute("id"); + if (id) assignments.set(id, group.id); + }); + }); + return assignments; +} + +function layerVisibility(document: Document): Map { + const result = new Map(); + document.querySelectorAll("layer-tree-layer[id]").forEach((element) => { + const id = element.getAttribute("id"); + if (id) result.set(id, element.getAttribute("checked") !== "Qt::Unchecked"); + }); + return result; +} + +function layerOrder(document: Document, mapLayers: Element[]): string[] { + const ids = Array.from(document.querySelectorAll("layer-tree-layer[id]")) + .map((element) => element.getAttribute("id") ?? "") + .filter(Boolean); + if (ids.length > 0) return ids.reverse(); + return mapLayers + .map((element) => text(element.querySelector(":scope > id"))) + .filter(Boolean) + .reverse(); +} + +function qgisVectorSource(dataSource: string, projectPath: string): string { + let source = dataSource.split("|", 1)[0]?.trim() ?? ""; + if (source.startsWith("file://")) { + try { + source = decodeURIComponent(new URL(source).pathname); + } catch { + source = source.slice("file://".length); + } + } + source = source.replace(/^['"]|['"]$/g, ""); + if (!source || isAbsolutePath(source) || /^[a-z]+:\/\//i.test(source)) return source; + const directory = projectPath.replace(/[/\\][^/\\]*$/, ""); + return normalizeJoinedPath(directory, source); +} + +function normalizeJoinedPath(directory: string, relative: string): string { + const separator = directory.includes("\\") ? "\\" : "/"; + const absolute = directory.startsWith("/"); + const parts = [ + ...directory.replace(/\\/g, "/").split("/"), + ...relative.replace(/\\/g, "/").split("/"), + ]; + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return `${absolute ? "/" : ""}${normalized.join(separator)}`; +} + +function isSupportedVectorLayer(element: Element, provider: string, source: string): boolean { + const extension = source.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase() ?? ""; + return ( + element.getAttribute("type")?.toLowerCase() === "vector" && + (provider === "ogr" || provider === "delimitedtext") && + SUPPORTED_VECTOR_EXTENSIONS.has(extension) + ); +} + +function unsupportedReason( + element: Element, + provider: string, + source: string, +): QgisProjectImportWarning["reason"] { + if (element.getAttribute("type")?.toLowerCase() !== "vector") { + return "non-vector"; + } + if (provider !== "ogr" && provider !== "delimitedtext") { + return "provider"; + } + if (!source) return "missing-source"; + return "format"; +} + +function parseLayerStyle(element: Element): LayerStyle { + const style: LayerStyle = structuredClone(DEFAULT_LAYER_STYLE); + const symbolLayer = element.querySelector("renderer-v2 symbols symbol layer"); + const options = new Map(); + symbolLayer?.querySelectorAll("Option[name]").forEach((option) => { + options.set(option.getAttribute("name") ?? "", option.getAttribute("value") ?? ""); + }); + const fill = qgisColor(options.get("color")); + const stroke = qgisColor(options.get("outline_color") ?? options.get("line_color")); + if (fill) { + style.fillColor = fill.color; + style.fillOpacity = fill.opacity; + style.markerColor = fill.color; + } + if (stroke) { + style.strokeColor = stroke.color; + } + const width = optionalNumber(options.get("outline_width") ?? options.get("line_width")); + if (width !== null) style.strokeWidth = Math.max(0, width * 3.78); + const size = optionalNumber(options.get("size")); + if (symbolLayer?.getAttribute("class") === "SimpleMarker" && size !== null) { + style.circleRadius = Math.max(1, (size * 3.78) / 2); + } + const textStyle = element.querySelector("labeling[type='simple'] settings text-style"); + const field = textStyle?.getAttribute("fieldName")?.trim(); + if (field) { + style.labels.enabled = true; + style.labels.field = field; + const color = qgisColor(textStyle?.getAttribute("textColor") ?? undefined); + if (color) style.labels.color = color.color; + const sizeValue = optionalNumber(textStyle?.getAttribute("fontSize") ?? undefined); + if (sizeValue !== null) style.labels.size = sizeValue; + } + return style; +} + +function qgisColor(value: string | undefined): { color: string; opacity: number } | null { + if (!value) return null; + const parts = value.split(",").map(Number); + if (parts.length < 3 || parts.slice(0, 3).some((part) => !Number.isFinite(part))) return null; + const [red, green, blue, alpha = 255] = parts; + return { + color: `#${[red, green, blue] + .map((part) => + Math.max(0, Math.min(255, Math.round(part))) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`, + opacity: Math.max(0, Math.min(1, alpha / 255)), + }; +} + +function parseOpacity(element: Element): number { + const value = optionalNumber(text(element.querySelector(":scope > layerOpacity"))); + return value !== null ? Math.max(0, Math.min(1, value)) : 1; +} + +function optionalNumber(value: string | undefined): number | null { + if (value == null || value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function uniqueLayerId(candidate: string, layers: GeoLibreLayer[]): string { + const base = candidate.trim() || `qgis-layer-${layers.length + 1}`; + let id = base; + let suffix = 2; + while (layers.some((layer) => layer.id === id)) id = `${base}-${suffix++}`; + return id; +} + +function numberText(element: Element | null | undefined): number { + return Number(text(element)); +} + +function text(element: Element | null | undefined): string { + return element?.textContent?.trim() ?? ""; +} + +function fileStem(path: string): string { + return ( + path + .split(/[/\\]/) + .pop() + ?.replace(/\.(qgz|qgs)$/i, "") ?? "" + ); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || path.startsWith("\\\\") || /^[A-Za-z]:[/\\]/.test(path); +} + +function slug(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "group" + ); +} diff --git a/apps/geolibre-desktop/src/lib/tauri-io.ts b/apps/geolibre-desktop/src/lib/tauri-io.ts index 194390d0a..fe35078da 100644 --- a/apps/geolibre-desktop/src/lib/tauri-io.ts +++ b/apps/geolibre-desktop/src/lib/tauri-io.ts @@ -2116,6 +2116,20 @@ export async function openProjectFile(): Promise<{ return { project, path: selected }; } +/** Pick a QGIS project and return its raw bytes for the import converter. */ +export async function openQgisProjectFile(): Promise<{ + data: ArrayBuffer; + path: string; +} | null> { + const result = await openLocalDataFileWithFallback({ + filters: [{ name: "QGIS Project", extensions: ["qgz", "qgs"] }], + accept: ".qgz,.qgs", + readBinary: true, + }); + if (!result?.data) return null; + return { data: result.data, path: result.path }; +} + /** * Thrown when a recent project is permanently gone (HTTP 404/410 or a local * file that no longer exists), signalling the caller that the entry can be diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index db666f0c5..eda3805d8 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -197,6 +197,7 @@ export const PLUGIN_TIERS: Record = { * so the profile UI can never be locked away). */ export type TopLevelMenuId = | "project" + | "import" | "edit" | "view" | "addData" @@ -214,6 +215,7 @@ export interface TopLevelMenuEntry { /** Hideable top-level menus, in toolbar order. */ export const TOP_LEVEL_MENUS: readonly TopLevelMenuEntry[] = [ { id: "project", labelKey: "toolbar.menu.project", tier: "basic" }, + { id: "import", labelKey: "toolbar.menu.import", tier: "basic" }, { id: "edit", labelKey: "toolbar.menu.edit", tier: "basic" }, { id: "view", labelKey: "toolbar.menu.view", tier: "basic" }, { id: "addData", labelKey: "toolbar.menu.addData", tier: "basic" }, From 18621b0880cbc70529eaa8b759b150e7de9480c4 Mon Sep 17 00:00:00 2001 From: giswqs Date: Wed, 29 Jul 2026 23:34:34 -0400 Subject: [PATCH 02/19] fix: address QGIS import review feedback --- .../geolibre-desktop/src/i18n/locales/en.json | 2 + .../src/lib/qgis-project-import.ts | 109 +++++-- package-lock.json | 302 ++++++++++++++++++ package.json | 1 + tests/qgis-project-import.test.ts | 135 ++++++++ 5 files changed, 514 insertions(+), 35 deletions(-) create mode 100644 tests/qgis-project-import.test.ts diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index c72a61b5d..246a5bbb6 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2408,6 +2408,8 @@ "provider": "The {{provider}} data provider is not supported.", "missing-source": "The layer has no readable data source.", "format": "The layer's file format is not supported.", + "remote-file": "Remote QGIS file sources are not supported yet.", + "network-path": "Network share paths are not loaded for security reasons.", "browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it." }, "openProjectFromUrlDesc": "Load a `.geolibre.json` project and add it to recent projects.", diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index 279d35c37..3dc8065e6 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -11,7 +11,14 @@ import { strFromU8, unzipSync } from "fflate"; export interface QgisProjectImportWarning { layerName: string; - reason: "non-vector" | "provider" | "missing-source" | "format" | "browser-local-file"; + reason: + | "non-vector" + | "provider" + | "missing-source" + | "format" + | "remote-file" + | "network-path" + | "browser-local-file"; provider?: string; } @@ -54,8 +61,8 @@ export function importQgisProject( const projectName = text(document.querySelector("title")) || fileStem(sourcePath) || "Imported QGIS Project"; const project = createEmptyProject(projectName, { mapView: parseMapView(document) }); - const groups = parseLayerGroups(document); - const groupByLayerId = layerGroupAssignments(document, groups); + const parsedGroups = parseLayerGroups(document); + const groupByLayerId = layerGroupAssignments(document, parsedGroups.ids); const visibilityByLayerId = layerVisibility(document); const mapLayers = Array.from(document.querySelectorAll("projectlayers > maplayer")); const byId = new Map( @@ -100,7 +107,7 @@ export function importQgisProject( } project.layers = layers; - project.layerGroups = groups.filter((group) => + project.layerGroups = parsedGroups.groups.filter((group) => layers.some((layer) => layer.groupId === group.id), ); project.metadata = { @@ -138,9 +145,11 @@ function parseMapView(document: Document): MapViewState { text(document.querySelector("mapcanvas > destinationsrs authid")) || text(document.querySelector("projectCrs authid")); if ([xmin, ymin, xmax, ymax].every(Number.isFinite)) { - const [west, south] = toWgs84(xmin, ymin, authId); - const [east, north] = toWgs84(xmax, ymax, authId); - if ([west, south, east, north].every(Number.isFinite)) { + const southwest = toWgs84(xmin, ymin, authId); + const northeast = toWgs84(xmax, ymax, authId); + if (southwest && northeast) { + const [west, south] = southwest; + const [east, north] = northeast; return { center: [(west + east) / 2, (south + north) / 2], zoom: zoomForBounds(west, south, east, north), @@ -153,12 +162,16 @@ function parseMapView(document: Document): MapViewState { return { center: [-100, 40], zoom: 2, bearing: 0, pitch: 0 }; } -function toWgs84(x: number, y: number, authId: string): [number, number] { - if (authId.toUpperCase() !== "EPSG:3857") return [x, y]; - return [ - (x / 20037508.34) * 180, - (180 / Math.PI) * (2 * Math.atan(Math.exp((y / 20037508.34) * Math.PI)) - Math.PI / 2), - ]; +function toWgs84(x: number, y: number, authId: string): [number, number] | null { + const normalized = authId.toUpperCase(); + if (normalized === "EPSG:4326" || normalized === "CRS:84") return [x, y]; + if (normalized === "EPSG:3857") { + return [ + (x / 20037508.34) * 180, + (180 / Math.PI) * (2 * Math.atan(Math.exp((y / 20037508.34) * Math.PI)) - Math.PI / 2), + ]; + } + return null; } function zoomForBounds(west: number, south: number, east: number, north: number): number { @@ -166,34 +179,47 @@ function zoomForBounds(west: number, south: number, east: number, north: number) return Math.max(0, Math.min(20, Math.log2(360 / span) - 0.75)); } -function parseLayerGroups(document: Document): LayerGroup[] { +function parseLayerGroups(document: Document): { + groups: LayerGroup[]; + ids: Map; +} { const root = document.querySelector("layer-tree-group"); - if (!root) return []; - return Array.from(root.children) - .filter((element) => element.tagName === "layer-tree-group") - .map((element, index) => ({ - id: `qgis-group-${index}-${slug(element.getAttribute("name") ?? "group")}`, - name: element.getAttribute("name") || "Group", + if (!root) return { groups: [], ids: new Map() }; + const ids = new Map(); + const groups = Array.from(root.querySelectorAll("layer-tree-group")).map((element, index) => { + const name = groupDisplayName(element, root); + const id = `qgis-group-${index}-${slug(name)}`; + ids.set(element, id); + return { + id, + name, collapsed: element.getAttribute("expanded") === "0", visible: element.getAttribute("checked") !== "Qt::Unchecked", opacity: 1, - })); + }; + }); + return { groups, ids }; } -function layerGroupAssignments(document: Document, groups: LayerGroup[]): Map { +function groupDisplayName(element: Element, root: Element): string { + const names: string[] = []; + let current: Element | null = element; + while (current && current !== root) { + const name = current.getAttribute("name")?.trim(); + if (name) names.unshift(name); + current = current.parentElement?.closest("layer-tree-group") ?? null; + } + return names.join(" / ") || "Group"; +} + +function layerGroupAssignments(document: Document, ids: Map): Map { const assignments = new Map(); - const root = document.querySelector("layer-tree-group"); - if (!root) return assignments; - Array.from(root.children) - .filter((element) => element.tagName === "layer-tree-group") - .forEach((element, index) => { - const group = groups[index]; - if (!group) return; - element.querySelectorAll("layer-tree-layer[id]").forEach((layer) => { - const id = layer.getAttribute("id"); - if (id) assignments.set(id, group.id); - }); - }); + document.querySelectorAll("layer-tree-layer[id]").forEach((layer) => { + const layerId = layer.getAttribute("id"); + const parentGroup = layer.parentElement?.closest("layer-tree-group"); + const groupId = parentGroup ? ids.get(parentGroup) : undefined; + if (layerId && groupId) assignments.set(layerId, groupId); + }); return assignments; } @@ -253,6 +279,8 @@ function isSupportedVectorLayer(element: Element, provider: string, source: stri return ( element.getAttribute("type")?.toLowerCase() === "vector" && (provider === "ogr" || provider === "delimitedtext") && + !isHttpSource(source) && + !isUncPath(source) && SUPPORTED_VECTOR_EXTENSIONS.has(extension) ); } @@ -269,6 +297,8 @@ function unsupportedReason( return "provider"; } if (!source) return "missing-source"; + if (isHttpSource(source)) return "remote-file"; + if (isUncPath(source)) return "network-path"; return "format"; } @@ -345,7 +375,8 @@ function uniqueLayerId(candidate: string, layers: GeoLibreLayer[]): string { } function numberText(element: Element | null | undefined): number { - return Number(text(element)); + const value = text(element); + return value === "" ? Number.NaN : Number(value); } function text(element: Element | null | undefined): string { @@ -365,6 +396,14 @@ function isAbsolutePath(path: string): boolean { return path.startsWith("/") || path.startsWith("\\\\") || /^[A-Za-z]:[/\\]/.test(path); } +function isHttpSource(path: string): boolean { + return /^https?:\/\//i.test(path); +} + +function isUncPath(path: string): boolean { + return path.startsWith("\\\\") || path.startsWith("//"); +} + function slug(value: string): string { return ( value diff --git a/package-lock.json b/package-lock.json index c8a8aa07c..bbc0dce1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^26.1.2", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", + "linkedom": "^0.18.13", "tsx": "^4.23.1", "typescript-eslint": "^8.65.0" }, @@ -11979,6 +11980,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -12616,6 +12631,48 @@ "utrie": "^1.0.2" } }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -12871,11 +12928,63 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, "node_modules/dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.4.12", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", @@ -12885,6 +12994,25 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -12997,6 +13125,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz", @@ -15052,6 +15193,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", @@ -15088,6 +15236,111 @@ "node": ">=16.0.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -16431,6 +16684,31 @@ "integrity": "sha512-PosanfyLckGXZbCX+aWmfmHWWhVPnLf9iKcUefaSGGw2IBOef5XdBdyl175LEqRy/sEOZ2SEz/l7K5S93BZlYQ==", "license": "ISC" }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -17696,6 +17974,23 @@ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", "license": "MIT" }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/numcodecs": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", @@ -20462,6 +20757,13 @@ "node": ">=12.17" } }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index f358eca65..6a008fdbf 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/node": "^26.1.2", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", + "linkedom": "^0.18.13", "tsx": "^4.23.1", "typescript-eslint": "^8.65.0" }, diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts new file mode 100644 index 000000000..2d2f210da --- /dev/null +++ b/tests/qgis-project-import.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { strToU8, zipSync } from "fflate"; +import { DOMParser } from "linkedom"; +import { importQgisProject } from "../apps/geolibre-desktop/src/lib/qgis-project-import"; + +globalThis.DOMParser = DOMParser as unknown as typeof globalThis.DOMParser; + +function projectXml( + options: { + authId?: string; + extent?: string; + dataSources?: Array<{ id: string; name: string; source: string }>; + } = {}, +): string { + const dataSources = options.dataSources ?? [ + { id: "roads", name: "Roads", source: "../data/roads.geojson" }, + { id: "cities", name: "Cities", source: "../data/cities.gpkg|layername=cities" }, + ]; + const mapLayers = dataSources + .map( + ({ id, name, source }) => ` + + ${id} + ${name} + ${source} + ogr + 0.75 + + + + + + + + `, + ) + .join(""); + + return ` + Imported map + + + + + + + + + ${mapLayers} + + ${options.extent ?? "-12524-6650"} + ${options.authId ?? "EPSG:4326"} + + `; +} + +describe("QGIS project import", () => { + it("imports vector layers, styles, visibility, nested groups, extent, and relative paths", () => { + const result = importQgisProject(projectXml(), "/work/projects/example.qgs"); + + assert.equal(result.project.name, "Imported map"); + assert.deepEqual(result.project.mapView.center, [-95.5, 37]); + assert.deepEqual( + result.project.layerGroups?.map((group) => group.name), + ["Transport", "Transport / Places"], + ); + assert.deepEqual( + result.project.layers.map((layer) => layer.name), + ["Cities", "Roads"], + ); + + const cities = result.project.layers[0]; + const roads = result.project.layers[1]; + assert.equal(cities.sourcePath, "/work/data/cities.gpkg"); + assert.equal(cities.groupId, result.project.layerGroups?.[1].id); + assert.equal(cities.opacity, 0.75); + assert.equal(cities.style.fillColor, "#ff0000"); + assert.equal(cities.style.fillOpacity, 128 / 255); + assert.equal(cities.style.labels.enabled, true); + assert.equal(cities.style.labels.field, "name"); + assert.equal(roads.visible, false); + assert.equal(roads.groupId, result.project.layerGroups?.[0].id); + assert.deepEqual(result.warnings, []); + }); + + it("reads the QGS document from a QGZ archive", () => { + const qgz = zipSync({ "nested/project.qgs": strToU8(projectXml()) }); + const result = importQgisProject(qgz, "/work/projects/example.qgz"); + assert.equal(result.project.name, "Imported map"); + assert.equal(result.project.layers.length, 2); + }); + + it("falls back to the default view for a missing or unsupported-CRS extent", () => { + const missing = importQgisProject(projectXml({ extent: "" }), "/work/example.qgs"); + assert.deepEqual(missing.project.mapView, { + center: [-100, 40], + zoom: 2, + bearing: 0, + pitch: 0, + }); + + const projected = importQgisProject(projectXml({ authId: "EPSG:32618" }), "/work/example.qgs"); + assert.deepEqual(projected.project.mapView, { + center: [-100, 40], + zoom: 2, + bearing: 0, + pitch: 0, + }); + }); + + it("warns and omits remote and UNC file sources that cannot be restored", () => { + const result = importQgisProject( + projectXml({ + dataSources: [ + { id: "roads", name: "Remote", source: "https://example.com/roads.geojson" }, + { id: "cities", name: "Network", source: "\\\\server\\share\\cities.gpkg" }, + ], + }), + "C:\\projects\\example.qgs", + ); + + assert.deepEqual(result.project.layers, []); + assert.deepEqual( + result.warnings.map((warning) => [warning.layerName, warning.reason]), + [ + ["Network", "network-path"], + ["Remote", "remote-file"], + ], + ); + }); +}); From 2329d72277925f002d9ea9d472f6188404fed24f Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 30 Jul 2026 22:05:08 -0400 Subject: [PATCH 03/19] fix: move import under project menu --- .../src/components/layout/TopToolbar.tsx | 8 +--- .../components/layout/toolbar/ImportMenu.tsx | 45 ------------------- .../components/layout/toolbar/ProjectMenu.tsx | 18 ++++++++ apps/geolibre-desktop/src/lib/ui-profile.ts | 8 +++- 4 files changed, 25 insertions(+), 54 deletions(-) delete mode 100644 apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 675e2e9f5..d56f576ca 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -123,7 +123,6 @@ import { ControlsMenu } from "./toolbar/ControlsMenu"; import { EditMenu } from "./toolbar/EditMenu"; import { ViewMenu } from "./toolbar/ViewMenu"; import { HelpMenu } from "./toolbar/HelpMenu"; -import { ImportMenu } from "./toolbar/ImportMenu"; import { OsmPbfDialogs } from "./toolbar/OsmPbfDialogs"; import { PluginsMenu } from "./toolbar/PluginsMenu"; import { PluginToolbarMenus } from "./toolbar/PluginToolbarMenus"; @@ -1425,6 +1424,7 @@ export function TopToolbar({ onOpenFromFile={() => void projectFiles.handleOpenFromFile()} onOpenFromUrl={() => projectFiles.setProjectUrlDialogOpen(true)} onOpenGallery={() => setGalleryDialogOpen(true)} + onImportQgisProject={() => void projectFiles.handleImportQgisProject()} onOpenRecent={(path) => { void projectFiles.handleOpenRecent(path).then((error) => { if (error) projectFiles.setActionError(error); @@ -1441,12 +1441,6 @@ export function TopToolbar({ onOpenOfflineBasemap={onOpenBasemapExtract} /> )} - {isMenuVisible(uiProfile, "import") && ( - void projectFiles.handleImportQgisProject()} - /> - )} {isMenuVisible(uiProfile, "edit") && ( )} diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx deleted file mode 100644 index e4a6ed69e..000000000 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ImportMenu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@geolibre/ui"; -import { FileInput, Import } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import type { ToolbarChrome } from "./constants"; - -interface ImportMenuProps { - chrome: ToolbarChrome; - onImportQgisProject: () => void; -} - -/** Import workflows that translate third-party project formats into GeoLibre. */ -export function ImportMenu({ chrome, onImportQgisProject }: ImportMenuProps) { - const { t } = useTranslation(); - return ( - - - - - - {t("toolbar.menu.import")} - - - - {t("toolbar.item.importQgisProjectEllipsis")} - - - - ); -} diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx index 7b594a257..805e8f509 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx @@ -16,6 +16,7 @@ import { Bookmark, Copy, FileCode2, + FileInput, FilePen, FilePlus2, FileText, @@ -23,6 +24,7 @@ import { FolderOpen, HardDriveDownload, History, + Import, LayoutGrid, Link2, Printer, @@ -43,6 +45,7 @@ interface ProjectMenuProps { onOpenFromFile: () => void; onOpenFromUrl: () => void; onOpenGallery: () => void; + onImportQgisProject: () => void; onOpenRecent: (path: string) => void; onSave: () => void; onSaveAs: () => void; @@ -63,6 +66,7 @@ export function ProjectMenu({ onOpenFromFile, onOpenFromUrl, onOpenGallery, + onImportQgisProject, onOpenRecent, onSave, onSaveAs, @@ -199,6 +203,20 @@ export function ProjectMenu({ )} + {show("project.import") && ( + + + + {t("toolbar.menu.import")} + + + + + {t("toolbar.item.importQgisProjectEllipsis")} + + + + )} {showSaveGroup && } {show("project.save") && ( diff --git a/apps/geolibre-desktop/src/lib/ui-profile.ts b/apps/geolibre-desktop/src/lib/ui-profile.ts index eda3805d8..74d0d9677 100644 --- a/apps/geolibre-desktop/src/lib/ui-profile.ts +++ b/apps/geolibre-desktop/src/lib/ui-profile.ts @@ -197,7 +197,6 @@ export const PLUGIN_TIERS: Record = { * so the profile UI can never be locked away). */ export type TopLevelMenuId = | "project" - | "import" | "edit" | "view" | "addData" @@ -215,7 +214,6 @@ export interface TopLevelMenuEntry { /** Hideable top-level menus, in toolbar order. */ export const TOP_LEVEL_MENUS: readonly TopLevelMenuEntry[] = [ { id: "project", labelKey: "toolbar.menu.project", tier: "basic" }, - { id: "import", labelKey: "toolbar.menu.import", tier: "basic" }, { id: "edit", labelKey: "toolbar.menu.edit", tier: "basic" }, { id: "view", labelKey: "toolbar.menu.view", tier: "basic" }, { id: "addData", labelKey: "toolbar.menu.addData", tier: "basic" }, @@ -258,6 +256,12 @@ export const MENU_ITEM_CATALOG: readonly MenuItemCatalogEntry[] = [ labelKey: "toolbar.item.openRecent", tier: "basic", }, + { + id: "project.import", + menuId: "project", + labelKey: "toolbar.menu.import", + tier: "basic", + }, { id: "project.save", menuId: "project", labelKey: "common.save", tier: "basic" }, { id: "project.saveAs", From 5b01d237052027e4fe7bebf1ade5e286d578ab25 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 30 Jul 2026 22:22:52 -0400 Subject: [PATCH 04/19] fix: load remote QGIS vector sources --- .../src/hooks/useProjectFileActions.ts | 10 ++- .../src/lib/qgis-project-import.ts | 65 +++++++++++++++++-- tests/qgis-project-import.test.ts | 52 ++++++++++++--- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 54a224c6b..af0518cbd 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -34,7 +34,11 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre"; import { shareAuthorizedFetch } from "../lib/share-gallery"; import { normalizeProjectUrl } from "../lib/urls"; import { resolveProjectXyzLayers } from "../lib/xyz-url"; -import { importQgisProject, type QgisProjectImportWarning } from "../lib/qgis-project-import"; +import { + importQgisProject, + materializeQgisRemoteLayers, + type QgisProjectImportWarning, +} from "../lib/qgis-project-import"; import type { MapControllerRef } from "../components/layout/toolbar/constants"; /** A pending "strip env vars before saving?" prompt. */ @@ -161,7 +165,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { const result = await openQgisProjectFile(); if (!result) return; try { - const imported = importQgisProject(result.data, result.path); + const imported = await materializeQgisRemoteLayers( + importQgisProject(result.data, result.path), + ); if (!isTauri()) { for (const layer of imported.project.layers) { if (layer.sourcePath && !isHttpUrl(layer.sourcePath)) { diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index 3dc8065e6..dab7b07ca 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -8,6 +8,7 @@ import { type MapViewState, } from "@geolibre/core"; import { strFromU8, unzipSync } from "fflate"; +import type { FeatureCollection } from "geojson"; export interface QgisProjectImportWarning { layerName: string; @@ -27,6 +28,51 @@ export interface QgisProjectImportResult { warnings: QgisProjectImportWarning[]; } +/** + * Fetch remote GeoJSON sources referenced by a parsed QGIS project. + * + * QGIS commonly stores HTTP-backed OGR layers behind GDAL's `/vsicurl/` + * prefix. The synchronous parser normalizes that prefix to an HTTP URL; this + * step materializes those features before the project enters the store because + * GeoLibre's GeoJSON renderer consumes an in-memory FeatureCollection. + */ +export async function materializeQgisRemoteLayers( + result: QgisProjectImportResult, + fetcher: typeof fetch = fetch, +): Promise { + const failedLayerIds = new Set(); + await Promise.all( + result.project.layers.map(async (layer) => { + const sourcePath = layer.sourcePath; + if (!sourcePath || !isHttpSource(sourcePath)) return; + try { + const response = await fetcher(sourcePath); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as unknown; + if (!isFeatureCollection(data)) + throw new Error("Response is not a GeoJSON FeatureCollection"); + layer.geojson = data; + } catch { + failedLayerIds.add(layer.id); + result.warnings.push({ + layerName: layer.name, + reason: "remote-file", + }); + } + }), + ); + if (failedLayerIds.size > 0) { + result.project.layers = result.project.layers.filter((layer) => !failedLayerIds.has(layer.id)); + const usedGroupIds = new Set( + result.project.layers.flatMap((layer) => (layer.groupId ? [layer.groupId] : [])), + ); + result.project.layerGroups = result.project.layerGroups?.filter((group) => + usedGroupIds.has(group.id), + ); + } + return result; +} + const SUPPORTED_VECTOR_EXTENSIONS = new Set([ "csv", "dxf", @@ -91,12 +137,15 @@ export function importQgisProject( id: uniqueLayerId(id, layers), name, type: "geojson", - source: { type: "geojson" }, + source: { + type: "geojson", + ...(isHttpSource(source) ? { url: source } : {}), + }, visible: visibilityByLayerId.get(id) ?? true, opacity: parseOpacity(element), style: parseLayerStyle(element), metadata: { - localFileReloadable: true, + ...(!isHttpSource(source) ? { localFileReloadable: true } : {}), importedFrom: "qgis", qgisLayerId: id, qgisProvider: provider, @@ -245,6 +294,7 @@ function layerOrder(document: Document, mapLayers: Element[]): string[] { function qgisVectorSource(dataSource: string, projectPath: string): string { let source = dataSource.split("|", 1)[0]?.trim() ?? ""; + source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); if (source.startsWith("file://")) { try { source = decodeURIComponent(new URL(source).pathname); @@ -279,7 +329,6 @@ function isSupportedVectorLayer(element: Element, provider: string, source: stri return ( element.getAttribute("type")?.toLowerCase() === "vector" && (provider === "ogr" || provider === "delimitedtext") && - !isHttpSource(source) && !isUncPath(source) && SUPPORTED_VECTOR_EXTENSIONS.has(extension) ); @@ -297,7 +346,6 @@ function unsupportedReason( return "provider"; } if (!source) return "missing-source"; - if (isHttpSource(source)) return "remote-file"; if (isUncPath(source)) return "network-path"; return "format"; } @@ -400,6 +448,15 @@ function isHttpSource(path: string): boolean { return /^https?:\/\//i.test(path); } +function isFeatureCollection(value: unknown): value is FeatureCollection { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "FeatureCollection" && + Array.isArray((value as { features?: unknown }).features) + ); +} + function isUncPath(path: string): boolean { return path.startsWith("\\\\") || path.startsWith("//"); } diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 2d2f210da..4a1bea477 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { strToU8, zipSync } from "fflate"; import { DOMParser } from "linkedom"; -import { importQgisProject } from "../apps/geolibre-desktop/src/lib/qgis-project-import"; +import { + importQgisProject, + materializeQgisRemoteLayers, +} from "../apps/geolibre-desktop/src/lib/qgis-project-import"; globalThis.DOMParser = DOMParser as unknown as typeof globalThis.DOMParser; @@ -112,24 +115,57 @@ describe("QGIS project import", () => { }); }); - it("warns and omits remote and UNC file sources that cannot be restored", () => { + it("normalizes GDAL VSI URLs and still rejects UNC file sources", () => { const result = importQgisProject( projectXml({ dataSources: [ - { id: "roads", name: "Remote", source: "https://example.com/roads.geojson" }, + { + id: "roads", + name: "Remote", + source: "/vsicurl/https://example.com/roads.geojson|layername=roads", + }, { id: "cities", name: "Network", source: "\\\\server\\share\\cities.gpkg" }, ], }), "C:\\projects\\example.qgs", ); - assert.deepEqual(result.project.layers, []); + assert.equal(result.project.layers.length, 1); + assert.equal(result.project.layers[0].sourcePath, "https://example.com/roads.geojson"); + assert.equal(result.project.layers[0].source.url, "https://example.com/roads.geojson"); + assert.equal(result.project.layers[0].metadata.localFileReloadable, undefined); assert.deepEqual( result.warnings.map((warning) => [warning.layerName, warning.reason]), - [ - ["Network", "network-path"], - ["Remote", "remote-file"], - ], + [["Network", "network-path"]], + ); + }); + + it("materializes remote GeoJSON and warns when a remote response cannot be loaded", async () => { + const result = importQgisProject( + projectXml({ + dataSources: [ + { id: "roads", name: "Good", source: "https://example.com/good.geojson" }, + { id: "cities", name: "Bad", source: "https://example.com/bad.geojson" }, + ], + }), + "/work/example.qgs", + ); + + await materializeQgisRemoteLayers(result, async (input) => { + const url = String(input); + if (url.endsWith("/bad.geojson")) return new Response("no", { status: 404 }); + return Response.json({ + type: "FeatureCollection", + features: [{ type: "Feature", geometry: null, properties: { name: "Test" } }], + }); + }); + + assert.equal(result.project.layers.length, 1); + assert.equal(result.project.layers[0].name, "Good"); + assert.equal(result.project.layers[0].geojson?.features.length, 1); + assert.deepEqual( + result.warnings.map((warning) => [warning.layerName, warning.reason]), + [["Bad", "remote-file"]], ); }); }); From 8b7da4f654a4be91cd39d42978aec02e18044df4 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 30 Jul 2026 22:38:34 -0400 Subject: [PATCH 05/19] feat: import QGIS GeoTIFF layers --- .../src/hooks/useProjectFileActions.ts | 35 +++++++++++++- .../geolibre-desktop/src/i18n/locales/en.json | 3 +- .../src/lib/qgis-project-import.ts | 48 ++++++++++++++++--- .../plugins/src/plugins/maplibre-raster.ts | 4 +- tests/qgis-project-import.test.ts | 34 +++++++++++++ 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index af0518cbd..0ff5da6b7 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -6,17 +6,18 @@ import { useAppStore, type GeoLibreLayer, } from "@geolibre/core"; -import { materializeEmbeddableVectorLayers } from "@geolibre/plugins"; +import { addRasterToMap, materializeEmbeddableVectorLayers } from "@geolibre/plugins"; import type { FeatureCollection } from "geojson"; import { type FormEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getPluginManager } from "./usePlugins"; +import { createAppAPI, getPluginManager } from "./usePlugins"; import { pluginManifestUrlsForIds } from "../lib/external-plugins"; import { browserSaveFallsBackToDownload, isAbsoluteLocalPath, isHttpUrl, isTauri, + loadDroppedRasterPaths, openProjectFile, openQgisProjectFile, openRecentProjectFile, @@ -177,8 +178,38 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { }); } } + for (const raster of imported.rasters) { + imported.warnings.push({ + layerName: raster.name, + reason: "browser-local-raster", + }); + } } loadProject(imported.project, null); + if (isTauri()) { + const app = createAppAPI(mapControllerRef); + for (const raster of imported.rasters) { + try { + const [loaded] = await loadDroppedRasterPaths([raster.sourcePath]); + if (!loaded) throw new Error("Unsupported raster path"); + await addRasterToMap(app, loaded.source, { + name: raster.name, + localPath: raster.sourcePath, + state: { + visible: raster.visible, + opacity: raster.opacity, + }, + zoomTo: false, + }); + } catch (error) { + console.error(`Failed to import QGIS raster "${raster.name}"`, error); + imported.warnings.push({ + layerName: raster.name, + reason: "format", + }); + } + } + } useAppStore.setState({ isDirty: true }); setQgisImportWarnings(imported.warnings); } catch (error) { diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 246a5bbb6..4e63583ca 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -2410,7 +2410,8 @@ "format": "The layer's file format is not supported.", "remote-file": "Remote QGIS file sources are not supported yet.", "network-path": "Network share paths are not loaded for security reasons.", - "browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it." + "browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it.", + "browser-local-raster": "The raster is referenced by a local path. Use GeoLibre Desktop to load it." }, "openProjectFromUrlDesc": "Load a `.geolibre.json` project and add it to recent projects.", "projectUrl": "Project URL", diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index dab7b07ca..03e9c1df0 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -19,15 +19,25 @@ export interface QgisProjectImportWarning { | "format" | "remote-file" | "network-path" - | "browser-local-file"; + | "browser-local-file" + | "browser-local-raster"; provider?: string; } export interface QgisProjectImportResult { project: GeoLibreProject; + rasters: QgisRasterImport[]; warnings: QgisProjectImportWarning[]; } +export interface QgisRasterImport { + id: string; + name: string; + sourcePath: string; + visible: boolean; + opacity: number; +} + /** * Fetch remote GeoJSON sources referenced by a parsed QGIS project. * @@ -92,6 +102,7 @@ const SUPPORTED_VECTOR_EXTENSIONS = new Set([ "tsv", "zip", ]); +const SUPPORTED_RASTER_EXTENSIONS = new Set(["tif", "tiff"]); /** Convert a QGIS project into a GeoLibre project without evaluating QGIS code. */ export function importQgisProject( @@ -116,13 +127,25 @@ export function importQgisProject( ); const warnings: QgisProjectImportWarning[] = []; const layers: GeoLibreLayer[] = []; + const rasters: QgisRasterImport[] = []; for (const id of layerOrder(document, mapLayers)) { const element = byId.get(id); if (!element) continue; const name = text(element.querySelector(":scope > layername")) || id || "QGIS layer"; const provider = text(element.querySelector(":scope > provider")).toLowerCase(); - const source = qgisVectorSource(text(element.querySelector(":scope > datasource")), sourcePath); + const source = qgisSourcePath(text(element.querySelector(":scope > datasource")), sourcePath); + + if (isSupportedRasterLayer(element, provider, source)) { + rasters.push({ + id, + name, + sourcePath: source, + visible: visibilityByLayerId.get(id) ?? true, + opacity: parseOpacity(element), + }); + continue; + } if (!isSupportedVectorLayer(element, provider, source)) { warnings.push({ @@ -164,7 +187,7 @@ export function importQgisProject( qgisProjectPath: sourcePath, qgisVersion: document.documentElement.getAttribute("version") ?? "", }; - return { project, warnings }; + return { project, rasters, warnings }; } function qgisProjectXml(data: ArrayBuffer | Uint8Array | string, sourcePath: string): string { @@ -292,7 +315,7 @@ function layerOrder(document: Document, mapLayers: Element[]): string[] { .reverse(); } -function qgisVectorSource(dataSource: string, projectPath: string): string { +function qgisSourcePath(dataSource: string, projectPath: string): string { let source = dataSource.split("|", 1)[0]?.trim() ?? ""; source = source.replace(/^\/vsicurl(?:_streaming)?\//i, ""); if (source.startsWith("file://")) { @@ -308,6 +331,10 @@ function qgisVectorSource(dataSource: string, projectPath: string): string { return normalizeJoinedPath(directory, source); } +function sourceExtension(source: string): string { + return source.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase() ?? ""; +} + function normalizeJoinedPath(directory: string, relative: string): string { const separator = directory.includes("\\") ? "\\" : "/"; const absolute = directory.startsWith("/"); @@ -325,12 +352,21 @@ function normalizeJoinedPath(directory: string, relative: string): string { } function isSupportedVectorLayer(element: Element, provider: string, source: string): boolean { - const extension = source.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase() ?? ""; return ( element.getAttribute("type")?.toLowerCase() === "vector" && (provider === "ogr" || provider === "delimitedtext") && !isUncPath(source) && - SUPPORTED_VECTOR_EXTENSIONS.has(extension) + SUPPORTED_VECTOR_EXTENSIONS.has(sourceExtension(source)) + ); +} + +function isSupportedRasterLayer(element: Element, provider: string, source: string): boolean { + return ( + element.getAttribute("type")?.toLowerCase() === "raster" && + provider === "gdal" && + !isHttpSource(source) && + !isUncPath(source) && + SUPPORTED_RASTER_EXTENSIONS.has(sourceExtension(source)) ); } diff --git a/packages/plugins/src/plugins/maplibre-raster.ts b/packages/plugins/src/plugins/maplibre-raster.ts index 7877de93e..55f96caa6 100644 --- a/packages/plugins/src/plugins/maplibre-raster.ts +++ b/packages/plugins/src/plugins/maplibre-raster.ts @@ -333,6 +333,8 @@ export async function addRasterToMap( state?: Partial; /** Existing map style layer beneath which the raster is inserted. */ beforeId?: string; + /** Whether to fit the map to the raster after loading. Defaults to true. */ + zoomTo?: boolean; } = {}, ): Promise { const control = await ensureRasterControl(app); @@ -350,7 +352,7 @@ export async function addRasterToMap( } const id = await control.addRaster(source, { name: options.name, - zoomTo: true, + zoomTo: options.zoomTo ?? true, // Safe to pass before the band count is known: the renderer applies a // colormap only in single-band mode and ignores it otherwise. ...(options.state || options.defaults?.colormap diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 4a1bea477..052050036 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -61,6 +61,23 @@ function projectXml( `; } +function rasterProjectXml(source = "../data/dem.tif"): string { + return ` + + + + + + dem + Elevation + ${source} + gdal + 0.6 + + + `; +} + describe("QGIS project import", () => { it("imports vector layers, styles, visibility, nested groups, extent, and relative paths", () => { const result = importQgisProject(projectXml(), "/work/projects/example.qgs"); @@ -95,6 +112,23 @@ describe("QGIS project import", () => { const result = importQgisProject(qgz, "/work/projects/example.qgz"); assert.equal(result.project.name, "Imported map"); assert.equal(result.project.layers.length, 2); + assert.deepEqual(result.rasters, []); + }); + + it("collects local GDAL GeoTIFFs for the desktop raster loader", () => { + const result = importQgisProject(rasterProjectXml(), "/work/projects/example.qgz"); + + assert.deepEqual(result.project.layers, []); + assert.deepEqual(result.rasters, [ + { + id: "dem", + name: "Elevation", + sourcePath: "/work/data/dem.tif", + visible: false, + opacity: 0.6, + }, + ]); + assert.deepEqual(result.warnings, []); }); it("falls back to the default view for a missing or unsupported-CRS extent", () => { From 0635120b06002c18c5c51f5ac7902ebe7a18391c Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 30 Jul 2026 22:49:12 -0400 Subject: [PATCH 06/19] fix: map QGIS OSM to built-in basemap --- .../src/lib/qgis-project-import.ts | 28 ++++++++++++++++++- tests/qgis-project-import.test.ts | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/geolibre-desktop/src/lib/qgis-project-import.ts b/apps/geolibre-desktop/src/lib/qgis-project-import.ts index 03e9c1df0..3353b71e4 100644 --- a/apps/geolibre-desktop/src/lib/qgis-project-import.ts +++ b/apps/geolibre-desktop/src/lib/qgis-project-import.ts @@ -1,4 +1,5 @@ import { + DEFAULT_BASEMAP, DEFAULT_LAYER_STYLE, createEmptyProject, type GeoLibreLayer, @@ -134,7 +135,16 @@ export function importQgisProject( if (!element) continue; const name = text(element.querySelector(":scope > layername")) || id || "QGIS layer"; const provider = text(element.querySelector(":scope > provider")).toLowerCase(); - const source = qgisSourcePath(text(element.querySelector(":scope > datasource")), sourcePath); + const dataSource = text(element.querySelector(":scope > datasource")); + + if (isOpenStreetMapBasemap(element, provider, dataSource)) { + project.basemapStyleUrl = DEFAULT_BASEMAP; + project.basemapVisible = visibilityByLayerId.get(id) ?? true; + project.basemapOpacity = parseOpacity(element); + continue; + } + + const source = qgisSourcePath(dataSource, sourcePath); if (isSupportedRasterLayer(element, provider, source)) { rasters.push({ @@ -370,6 +380,22 @@ function isSupportedRasterLayer(element: Element, provider: string, source: stri ); } +function isOpenStreetMapBasemap(element: Element, provider: string, dataSource: string): boolean { + if (element.getAttribute("type")?.toLowerCase() !== "raster" || provider !== "wms") { + return false; + } + const params = new URLSearchParams(dataSource); + if (params.get("type")?.toLowerCase() !== "xyz") return false; + const tileUrl = params.get("url"); + if (!tileUrl) return false; + try { + const host = new URL(tileUrl).hostname.toLowerCase(); + return host === "tile.openstreetmap.org" || /^[abc]\.tile\.openstreetmap\.org$/.test(host); + } catch { + return false; + } +} + function unsupportedReason( element: Element, provider: string, diff --git a/tests/qgis-project-import.test.ts b/tests/qgis-project-import.test.ts index 052050036..62a6e11a5 100644 --- a/tests/qgis-project-import.test.ts +++ b/tests/qgis-project-import.test.ts @@ -78,6 +78,23 @@ function rasterProjectXml(source = "../data/dem.tif"): string { `; } +function osmBasemapProjectXml(): string { + return ` + + + + + + osm + OpenStreetMap + crs=EPSG:3857&type=xyz&url=https://tile.openstreetmap.org/%7Bz%7D/%7Bx%7D/%7By%7D.png&zmax=19 + wms + 0.7 + + + `; +} + describe("QGIS project import", () => { it("imports vector layers, styles, visibility, nested groups, extent, and relative paths", () => { const result = importQgisProject(projectXml(), "/work/projects/example.qgs"); @@ -131,6 +148,17 @@ describe("QGIS project import", () => { assert.deepEqual(result.warnings, []); }); + it("maps QGIS OpenStreetMap XYZ layers to the built-in basemap", () => { + const result = importQgisProject(osmBasemapProjectXml(), "/work/example.qgs"); + + assert.equal(result.project.basemapStyleUrl, "https://tiles.openfreemap.org/styles/liberty"); + assert.equal(result.project.basemapVisible, false); + assert.equal(result.project.basemapOpacity, 0.7); + assert.deepEqual(result.project.layers, []); + assert.deepEqual(result.rasters, []); + assert.deepEqual(result.warnings, []); + }); + it("falls back to the default view for a missing or unsupported-CRS extent", () => { const missing = importQgisProject(projectXml({ extent: "" }), "/work/example.qgs"); assert.deepEqual(missing.project.mapView, { From efed7612a82124979deda4ab1c8ed79900e13ac7 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 30 Jul 2026 22:57:18 -0400 Subject: [PATCH 07/19] fix: authorize imported QGIS rasters --- apps/geolibre-desktop/src-tauri/src/lib.rs | 24 +++++++++++---- .../layout/toolbar/ProjectFileDialogs.tsx | 30 ++++++++----------- .../src/hooks/useProjectFileActions.ts | 6 ++-- .../geolibre-desktop/src/i18n/locales/en.json | 1 - apps/geolibre-desktop/src/lib/tauri-io.ts | 14 +++++++-- 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/apps/geolibre-desktop/src-tauri/src/lib.rs b/apps/geolibre-desktop/src-tauri/src/lib.rs index 709db49b9..d0b70d1aa 100644 --- a/apps/geolibre-desktop/src-tauri/src/lib.rs +++ b/apps/geolibre-desktop/src-tauri/src/lib.rs @@ -397,20 +397,32 @@ fn read_local_file(path: String) -> Result { .map_err(|error| format!("Could not read local file: {error}")) } -/// Add one user-selected GeoTIFF to the asset-protocol scope. The filesystem -/// and asset scopes are separate in Tauri; dialogs and native drops grant the -/// former, but maplibre-gl-raster fetches through the latter for range reads. +/// Add one GeoTIFF to the asset-protocol scope. The filesystem and asset scopes +/// are separate in Tauri; dialogs and native drops grant the former, but +/// maplibre-gl-raster fetches through the latter for range reads. A GeoTIFF +/// referenced by an imported QGIS project is also accepted when that project +/// file itself was explicitly selected by the user. #[tauri::command] -fn allow_raster_asset(app: tauri::AppHandle, path: String) -> Result<(), String> { +fn allow_raster_asset( + app: tauri::AppHandle, + path: String, + qgis_project_path: Option, +) -> Result<(), String> { let lower = path.to_ascii_lowercase(); if !is_safe_absolute_path(&path) || !(lower.ends_with(".tif") || lower.ends_with(".tiff")) { return Err(format!( "Refusing to expose \"{path}\": not an absolute GeoTIFF path" )); } - if !app.fs_scope().is_allowed(&path) { + let selected_qgis_project = qgis_project_path.is_some_and(|project_path| { + let lower = project_path.to_ascii_lowercase(); + is_safe_absolute_path(&project_path) + && (lower.ends_with(".qgs") || lower.ends_with(".qgz")) + && app.fs_scope().is_allowed(&project_path) + }); + if !app.fs_scope().is_allowed(&path) && !selected_qgis_project { return Err(format!( - "Refusing to expose \"{path}\": the file was not selected or dropped by the user" + "Refusing to expose \"{path}\": neither the file nor its QGIS project was selected by the user" )); } app.asset_protocol_scope() diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx index abde58b05..69047e848 100644 --- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx +++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectFileDialogs.tsx @@ -104,25 +104,21 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) { {t("toolbar.item.qgisImportComplete")} - {projectFiles.qgisImportWarnings?.length - ? t("toolbar.item.qgisImportWarnings", { - count: projectFiles.qgisImportWarnings.length, - }) - : t("toolbar.item.qgisImportSuccess")} + {t("toolbar.item.qgisImportWarnings", { + count: projectFiles.qgisImportWarnings?.length ?? 0, + })} - {projectFiles.qgisImportWarnings?.length ? ( -
    - {projectFiles.qgisImportWarnings.map((warning, index) => ( -
  • - {warning.layerName}:{" "} - {t(`toolbar.item.qgisImportReason.${warning.reason}`, { - provider: warning.provider || t("toolbar.item.qgisUnknownProvider"), - })} -
  • - ))} -
- ) : null} +
    + {projectFiles.qgisImportWarnings?.map((warning, index) => ( +
  • + {warning.layerName}:{" "} + {t(`toolbar.item.qgisImportReason.${warning.reason}`, { + provider: warning.provider || t("toolbar.item.qgisUnknownProvider"), + })} +
  • + ))} +