Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ interface AddDataDialogProps {
* clicked PostGIS table.
*/
initialPostgres?: OpenAddDataPostgres;
/** Service URL supplied by a browser-extension deep link. */
initialUrl?: string;
/**
* The layer that deep link asked for — a WMS `LAYERS` value, a WFS feature
* type, a vector tile source layer. Prefilled so the form the extension opens
* is complete rather than an endpoint the user must still name a layer on.
*/
initialLayer?: string;
/** Style document accompanying a deep-linked vector tileset. */
initialStyleUrl?: string;
}

/**
Expand All @@ -55,20 +65,29 @@ function renderSource(
kind: AddDataKind,
initialDeckVizKind: string | undefined,
initialPostgres: OpenAddDataPostgres | undefined,
initialUrl: string | undefined,
initialLayer: string | undefined,
initialStyleUrl: string | undefined,
) {
switch (kind) {
case "xyz":
return <XyzSource />;
return <XyzSource initialUrl={initialUrl} />;
case "wms":
return <WmsSource />;
return <WmsSource initialUrl={initialUrl} initialLayers={initialLayer} />;
case "wfs":
return <WfsSource />;
return <WfsSource initialUrl={initialUrl} initialTypeName={initialLayer} />;
case "wmts":
return <WmtsSource />;
return <WmtsSource initialUrl={initialUrl} />;
case "ogc-features":
return <OgcFeaturesSource />;
return <OgcFeaturesSource initialUrl={initialUrl} />;
case "ogc-vector-tiles":
return <OgcVectorTilesSource />;
return (
<OgcVectorTilesSource
initialUrl={initialUrl}
initialStyleUrl={initialStyleUrl}
initialSourceLayers={initialLayer}
/>
);
case "gpx":
return <GpxSource />;
case "georss":
Expand All @@ -84,7 +103,7 @@ function renderSource(
case "mbtiles":
return <MbtilesSource />;
case "arcgis":
return <ArcGISSource />;
return <ArcGISSource initialUrl={initialUrl} />;
case "postgres":
return <PostgresSource initialPostgres={initialPostgres} />;
case "video":
Expand All @@ -107,6 +126,9 @@ export function AddDataDialog({
onOpenChange,
initialDeckVizKind,
initialPostgres,
initialUrl,
initialLayer,
initialStyleUrl,
}: AddDataDialogProps) {
const { t } = useTranslation();
const open = kind !== null;
Expand Down Expand Up @@ -158,7 +180,14 @@ export function AddDataDialog({

{kind ? (
<AddDataShellProvider value={contextValue}>
{renderSource(kind, initialDeckVizKind, initialPostgres)}
{renderSource(
kind,
initialDeckVizKind,
initialPostgres,
initialUrl,
initialLayer,
initialStyleUrl,
)}
</AddDataShellProvider>
) : null}
</DialogContent>
Expand Down
20 changes: 19 additions & 1 deletion apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { IS_MAS_BUILD } from "../../lib/build-flags";
import { masHidesDataSource } from "../../lib/mas-build";
import { IS_STORE_BUILD } from "../../lib/updates";
import { AddDataDialog, type AddDataKind } from "./AddDataDialog";
import { serviceUrlParameter } from "../../lib/data-url";
import {
OPEN_ADD_DATA_EVENT,
type OpenAddDataDetail,
Expand Down Expand Up @@ -1029,7 +1030,16 @@ export function TopToolbar({
{} as Record<ToolbarMapControl, boolean>,
),
);
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(null);
const [initialService, setInitialService] = useState(() =>
viewer || typeof window === "undefined" ? null : serviceUrlParameter(window.location.search),
);
const [addDataKind, setAddDataKind] = useState<AddDataKind | null>(() => {
const kind = initialService?.kind as AddDataKind | undefined;
// Every other path that opens this dialog from outside the component
// filters MAS-hidden sources first; a deep link must not be the way around
// that, even though no service kind is hidden today.
return kind && !masHidesDataSource(kind) ? kind : null;
});
const [addDataTargetGroupId, setAddDataTargetGroupId] = useState<string | null>(null);
const addDataInitialLayerIdsRef = useRef<Set<string>>(new Set());
// Every path that opens the dialog outside the OPEN_ADD_DATA_EVENT listener
Expand Down Expand Up @@ -2085,6 +2095,13 @@ export function TopToolbar({
mapControllerRef={mapControllerRef}
initialDeckVizKind={addDataDeckVizKind}
initialPostgres={addDataPostgres}
initialUrl={addDataKind === initialService?.kind ? initialService.url : undefined}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
initialLayer={
addDataKind === initialService?.kind ? (initialService.layer ?? undefined) : undefined
}
initialStyleUrl={
addDataKind === initialService?.kind ? (initialService.styleUrl ?? undefined) : undefined
}
onOpenChange={(open: boolean) => {
if (!open) {
if (addDataTargetGroupId) {
Expand All @@ -2097,6 +2114,7 @@ export function TopToolbar({
}
}
setAddDataKind(null);
setInitialService(null);
setAddDataTargetGroupId(null);
setAddDataDeckVizKind(undefined);
setAddDataPostgres(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ const URL_PLACEHOLDER_KEYS = {
"image-service": "addData.arcgis.imageServiceUrlPlaceholder",
} as const satisfies Record<ArcGISLayerType, string>;

export function ArcGISSource() {
export function ArcGISSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.arcgis.defaultName"));
const [arcgisLayerType, setArcgisLayerType] = useState<ArcGISLayerType>("feature");
const [arcgisSourceType, setArcgisSourceType] = useState<ArcGISSourceType>("url");
const [arcgisUrl, setArcgisUrl] = useState("");
const [arcgisUrl, setArcgisUrl] = useState(initialUrl);
const [arcgisItemId, setArcgisItemId] = useState("");
const [arcgisPortalUrl, setArcgisPortalUrl] = useState("");
const [arcgisAccessToken, setArcgisAccessToken] = useState("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,22 @@ interface OgcFeaturesSample {
* feature count is reached, because a single `/items` request returns only one
* server-sized page.
*/
export function OgcFeaturesSource() {
export function OgcFeaturesSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.ogcFeatures.defaultName"));
const [endpoint, setEndpoint] = useState(ogcFeaturesFormCache?.endpoint ?? "");
const [collectionId, setCollectionId] = useState(ogcFeaturesFormCache?.collectionId ?? "");
const [endpoint, setEndpoint] = useState(initialUrl || ogcFeaturesFormCache?.endpoint || "");
// See WmsSource: submitting prefers this field over the collection id in the
// URL, so a value cached from another service would quietly request that
// collection from the deep-linked one.
const serviceCache = initialUrl ? null : ogcFeaturesFormCache;
const [collectionId, setCollectionId] = useState(serviceCache?.collectionId ?? "");
const [maxFeatures, setMaxFeatures] = useState(
ogcFeaturesFormCache?.maxFeatures ?? String(DEFAULT_OGC_FEATURES_MAX_FEATURES),
);
const [bbox, setBbox] = useState(ogcFeaturesFormCache?.bbox ?? "");
const [datetime, setDatetime] = useState(ogcFeaturesFormCache?.datetime ?? "");
const [collectionOptions, setCollectionOptions] = useState<OgcFeaturesCollectionOption[]>(
ogcFeaturesFormCache?.options ?? [],
serviceCache?.options ?? [],
);
const [isRetrieving, setIsRetrieving] = useState(false);
const [retrieveError, setRetrieveError] = useState<string | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ interface OgcSample {
* API TileJSON often omits them). Rendering uses GeoLibre's default per-source
* layer styling; the style's own paint is not applied.
*/
export function OgcVectorTilesSource() {
export function OgcVectorTilesSource({
initialUrl = "",
initialStyleUrl = "",
initialSourceLayers = "",
}: {
initialUrl?: string;
initialStyleUrl?: string;
initialSourceLayers?: string;
}) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.ogcVectorTiles.defaultName"));
const [tilesUrl, setTilesUrl] = useState("");
const [styleUrl, setStyleUrl] = useState("");
const [sourceLayersText, setSourceLayersText] = useState("");
const [tilesUrl, setTilesUrl] = useState(initialUrl);
const [styleUrl, setStyleUrl] = useState(initialStyleUrl);
const [sourceLayersText, setSourceLayersText] = useState(initialSourceLayers);

// Cancel the in-flight metadata/style/collections fetches if the dialog closes
// mid-request, so a slow response cannot add the layer after the user leaves.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,28 @@ interface WfsFormCache {
}
let wfsFormCache: WfsFormCache | null = null;

export function WfsSource() {
export function WfsSource({
initialUrl = "",
initialTypeName = "",
}: {
initialUrl?: string;
initialTypeName?: string;
}) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wfs.defaultName"));
const [wfsEndpoint, setWfsEndpoint] = useState(wfsFormCache?.endpoint ?? "");
const [wfsTypeName, setWfsTypeName] = useState(wfsFormCache?.typeName ?? "");
const [wfsEndpoint, setWfsEndpoint] = useState(initialUrl || wfsFormCache?.endpoint || "");
// See WmsSource: a deep link's endpoint must not inherit the feature type or
// the retrieved type list cached from an unrelated service.
const serviceCache = initialUrl ? null : wfsFormCache;
const [wfsTypeName, setWfsTypeName] = useState(initialTypeName || (serviceCache?.typeName ?? ""));
const [wfsVersion, setWfsVersion] = useState(wfsFormCache?.version ?? "2.0.0");
const [wfsOutputFormat, setWfsOutputFormat] = useState(
wfsFormCache?.outputFormat ?? "application/json",
);
const [wfsSrsName, setWfsSrsName] = useState(wfsFormCache?.srsName ?? "EPSG:4326");
const [wfsMaxFeatures, setWfsMaxFeatures] = useState(wfsFormCache?.maxFeatures ?? "1000");
const [typeOptions, setTypeOptions] = useState<WfsFeatureTypeOption[]>(
wfsFormCache?.options ?? [],
serviceCache?.options ?? [],
);
const [isRetrieving, setIsRetrieving] = useState(false);
const [retrieveError, setRetrieveError] = useState<string | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,28 +38,40 @@ interface WmsFormCache {
}
let wmsFormCache: WmsFormCache | null = null;

export function WmsSource() {
export function WmsSource({
initialUrl = "",
initialLayers = "",
}: {
initialUrl?: string;
initialLayers?: string;
}) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wms.defaultName"));
const [wmsEndpoint, setWmsEndpoint] = useState(wmsFormCache?.endpoint ?? "");
const [wmsLayers, setWmsLayers] = useState(wmsFormCache?.layers ?? "");
const [wmsStyles, setWmsStyles] = useState(wmsFormCache?.styles ?? "");
const [wmsEndpoint, setWmsEndpoint] = useState(initialUrl || wmsFormCache?.endpoint || "");
// A deep link brings its own service, so everything the cache holds *about a
// service* belongs to a different one: its layers, styles, retrieved layer
// list and negotiated version. Pairing a fresh endpoint with any of those
// would describe a service this form is no longer pointed at. Generic
// preferences (image format, transparency, tile size) still carry over.
const serviceCache = initialUrl ? null : wmsFormCache;
const [wmsLayers, setWmsLayers] = useState(initialLayers || (serviceCache?.layers ?? ""));
const [wmsStyles, setWmsStyles] = useState(serviceCache?.styles ?? "");
const [wmsFormat, setWmsFormat] = useState(wmsFormCache?.format ?? "image/png");
const [wmsTransparent, setWmsTransparent] = useState(wmsFormCache?.transparent ?? true);
const [wmsTileSize, setWmsTileSize] = useState(wmsFormCache?.tileSize ?? "256");
const [wmsVersion, setWmsVersion] = useState(wmsFormCache?.version ?? "1.1.1");
const [wmsVersion, setWmsVersion] = useState(serviceCache?.version ?? "1.1.1");
// True while the version has an explicit source — the selector, a pasted
// URL's VERSION parameter, or a saved service entry. Capabilities
// auto-detection only fills the version in when no explicit source exists.
// Mirrored in a ref so the async retrieve handler reads the value current at
// response time, not the one captured when the button was clicked.
const [versionTouched, setVersionTouched] = useState(wmsFormCache?.versionTouched ?? false);
const [versionTouched, setVersionTouched] = useState(serviceCache?.versionTouched ?? false);
const versionTouchedRef = useRef(versionTouched);
const markVersionTouched = (touched: boolean) => {
versionTouchedRef.current = touched;
setVersionTouched(touched);
};
const [layerOptions, setLayerOptions] = useState<WmsLayerOption[]>(wmsFormCache?.options ?? []);
const [layerOptions, setLayerOptions] = useState<WmsLayerOption[]>(serviceCache?.options ?? []);
const [isRetrieving, setIsRetrieving] = useState(false);
const [retrieveError, setRetrieveError] = useState<string | null>(null);
const layerListId = useId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
import { serviceFieldString, type ServiceFields } from "../service-library";
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";

export function WmtsSource() {
export function WmtsSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.wmts.defaultName"));
const [wmtsUrl, setWmtsUrl] = useState("");
const [wmtsUrl, setWmtsUrl] = useState(initialUrl);
const [wmtsTileSize, setWmtsTileSize] = useState("256");

const getFields = (): ServiceFields => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { ServiceLibrarySection } from "../ServiceLibrarySection";
import { serviceFieldBoolean, serviceFieldString, type ServiceFields } from "../service-library";
import { AddDataSourceForm, SampleDataSelect, useAddDataSource } from "../shared";

export function XyzSource() {
export function XyzSource({ initialUrl = "" }: { initialUrl?: string }) {
const { t } = useTranslation();
const source = useAddDataSource(t("addData.xyz.defaultName"));
const [xyzUrl, setXyzUrl] = useState("");
const [xyzUrl, setXyzUrl] = useState(initialUrl);
const [xyzTileSize, setXyzTileSize] = useState("256");
const [xyzShortUrl, setXyzShortUrl] = useState(false);

Expand Down
3 changes: 2 additions & 1 deletion apps/geolibre-desktop/src/hooks/useStartupProject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAppStore, type MapProjection } from "@geolibre/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { dataUrlParameters } from "../lib/data-url";
import { dataUrlParameters, serviceUrlParameter } from "../lib/data-url";
import { isTauri } from "../lib/is-tauri";
import { projectUrlFromLocation } from "../lib/project-url";
import { planStartup, startupDefaultProjection, type StartupPlan } from "../lib/startup-project";
Expand Down Expand Up @@ -36,6 +36,7 @@ const RESTORE_GATE_TIMEOUT_MS = 10_000;
function hasExplicitLaunchPayload(): boolean {
if (projectUrlFromLocation() !== null) return true;
if (dataUrlParameters(window.location.search) !== null) return true;
if (serviceUrlParameter(window.location.search) !== null) return true;
return false;
}

Expand Down
32 changes: 32 additions & 0 deletions apps/geolibre-desktop/src/lib/data-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ export interface DataUrlParameter {
dataUrl: string;
styleUrl: string | null;
}
const SERVICE_KINDS = new Set([
"xyz",
"wms",
"wmts",
"wfs",
"ogc-features",
"ogc-vector-tiles",
"arcgis",
]);

export interface ServiceUrlParameter {
kind: string;
url: string;
/** The requested layer: WMS `LAYERS`, WFS `typeName`, a tile source layer. */
layer: string | null;
/** A style document naming the source layers of a vector tileset. */
styleUrl: string | null;
}

export function serviceUrlParameter(search: string): ServiceUrlParameter | null {
const params = new URLSearchParams(search);
const kind = params.get("add");
const rawUrl = params.get("serviceUrl");
const url = httpUrl(rawUrl)?.replace(/%7B/gi, "{").replace(/%7D/gi, "}") ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serviceUrlParameter restores %7B/%7D{/} across the whole decoded serviceUrl, for every service kind — not just xyz/wmts/ogc-vector-tiles, which are the only kinds that actually use {z}/{x}/{y} tile templates. For wms, wfs, ogc-features, and arcgis deep links, this blanket replace can silently corrupt a URL whose query string legitimately contains those percent-encoded sequences for an unrelated reason (e.g. an auth token or signed parameter that happens to encode {/}), turning them into literal braces the origin server never sent.

Consider scoping the placeholder restoration to only the kinds that need it:

Suggested change
const url = httpUrl(rawUrl)?.replace(/%7B/gi, "{").replace(/%7D/gi, "}") ?? null;
const needsTileBraces = kind === "xyz" || kind === "wmts" || kind === "ogc-vector-tiles";
const url = httpUrl(rawUrl);
const restoredUrl =
url && needsTileBraces ? url.replace(/%7B/gi, "{").replace(/%7D/gi, "}") : url;

(and use restoredUrl below instead of url, with the !kind || !SERVICE_KINDS.has(kind) check moved before this to know kind first).

Confidence: medium — narrow real-world trigger, but a real correctness gap introduced by this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #1969 (landed after this PR merged) — brace restoration is now limited to xyz/wmts/ogc-vector-tiles, with a test asserting a WMS token keeps its encoded braces.

if (!kind || !SERVICE_KINDS.has(kind) || !url) return null;
return {
kind,
url,
layer: params.get("serviceLayer")?.trim() || null,
styleUrl: httpUrl(params.get("serviceStyle")),
};
}
export interface RemoteGeoJsonLayer {
data: FeatureCollection;
name: string;
Expand Down
Loading
Loading