From 389508c920328730a6d026a46a44b3ae848163c8 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 9 Aug 2026 20:02:27 -0400 Subject: [PATCH 1/4] feat(share): warn when a shared project references data recipients cannot load A `.geolibre.json` is mostly references. The publish path embeds local vector data, but every tile template, COG, PMTiles endpoint, OGC service, and hosted feature service stays a URL. So a project can upload cleanly and still draw nothing for the recipient: the service wanted a token the author holds and the upload strips, the host sends no cross-origin headers so it works in the desktop app and fails in the browser viewer, or the reference never left the author's machine. The author finds out when someone tells them the map is empty, if they tell them. Add a pre-flight readiness check that runs when the Share dialog opens. `share-readiness.ts` splits it in two so the classification stays pure and testable. `collectShareSources` walks every place a layer can hide a reference (`source.url`/`data`/`tiles`/`urls`/`baseUrl`/`arcgisQueryUrl`, `metadata.originalUrl`/`tileUrl`/`localFilePath`/`localBytesUrl`, `sourcePath`) plus the basemap style and plugin manifests, and settles what needs no network: a filesystem path, a private-network or single-label host, a URL whose credential `redactUrlCredentials` will strip, a populated credential field, a query-backed layer that names no source at all. `probeShareSources` resolves the rest with one anonymous HEAD per distinct target (a ranged GET retry for hosts that refuse HEAD), capped and short-timed out. Two things the classification reuses rather than re-derives: which layers the publish path embeds comes from `isEmbeddableLocalVectorLayer`, and whether a credential survives the upload comes from the same redaction rules the upload applies. A tile template collapses to its origin before probing, because substituting a nominal 0/0/0 tile 404s on any service whose data starts deeper and would report a healthy basemap as missing. The probes deliberately go through the browser's `fetch`, not the desktop app's native HTTP bypass, so a cross-origin rejection surfaces as one instead of being masked by a request that is not subject to CORS. That is what the recipient's browser will do. The dialog lists one row per layer with its worst verdict, a plain-language reason, and a fix. It never gates the Share button: an author sharing an intranet map with intranet colleagues is doing the right thing and should not have to fight a warning. Fixes #1671 --- .../components/layout/ShareProjectDialog.tsx | 160 ++++- .../geolibre-desktop/src/i18n/locales/ar.json | 20 + .../geolibre-desktop/src/i18n/locales/de.json | 20 + .../geolibre-desktop/src/i18n/locales/en.json | 22 +- .../geolibre-desktop/src/i18n/locales/es.json | 20 + .../geolibre-desktop/src/i18n/locales/fa.json | 22 +- .../geolibre-desktop/src/i18n/locales/fr.json | 20 + .../geolibre-desktop/src/i18n/locales/hi.json | 20 + .../geolibre-desktop/src/i18n/locales/id.json | 20 + .../geolibre-desktop/src/i18n/locales/it.json | 20 + .../geolibre-desktop/src/i18n/locales/ja.json | 20 + .../geolibre-desktop/src/i18n/locales/ka.json | 20 + .../geolibre-desktop/src/i18n/locales/ko.json | 20 + .../geolibre-desktop/src/i18n/locales/nl.json | 20 + .../geolibre-desktop/src/i18n/locales/pt.json | 20 + .../geolibre-desktop/src/i18n/locales/ru.json | 20 + .../geolibre-desktop/src/i18n/locales/th.json | 20 + .../geolibre-desktop/src/i18n/locales/tr.json | 20 + .../geolibre-desktop/src/i18n/locales/zh.json | 20 + .../src/lib/share-readiness.ts | 585 ++++++++++++++++++ docs/features.md | 1 + docs/user-guide/projects.md | 11 + packages/core/src/index.ts | 1 + tests/share-readiness.test.ts | 452 ++++++++++++++ 24 files changed, 1568 insertions(+), 6 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/share-readiness.ts create mode 100644 tests/share-readiness.test.ts diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 56ed79dd38..133a0f07b6 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -1,3 +1,5 @@ +import { useAppStore } from "@geolibre/core"; +import { isEmbeddableLocalVectorLayer } from "@geolibre/plugins"; import { Button, Dialog, @@ -9,7 +11,16 @@ import { Label, Select, } from "@geolibre/ui"; -import { Check, Copy, ExternalLink, KeyRound, Loader2, Share2 } from "lucide-react"; +import { + Check, + CircleCheck, + Copy, + ExternalLink, + KeyRound, + Loader2, + Share2, + TriangleAlert, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings"; @@ -25,6 +36,11 @@ import { type ShareUploadResult, type ShareVisibility, } from "../../lib/share-geolibre"; +import { + checkShareReadiness, + type ShareReadinessItem, + type ShareReadinessReport, +} from "../../lib/share-readiness"; import { openSettingsSection } from "./SettingsDialog"; interface ShareProjectDialogProps { @@ -54,6 +70,55 @@ function accountSettingsUrl(): string | null { return base ? `${base}/settings` : null; } +/** + * The plain-language reason shown for a verdict, and what the author can do + * about it. Keyed off the reason rather than the status so an unreachable host + * and a stripped credential read differently even though both are fatal for a + * recipient. An `unchecked` verdict short-circuits: whatever reason it carries, + * the honest thing to say is that the check did not settle it. + */ +function readinessCopyKeys(item: ShareReadinessItem) { + if (item.status === "unchecked") { + return { reason: "share.readinessReasonUnchecked", advice: null } as const; + } + switch (item.reason) { + case "credential-stripped": + return { + reason: "share.readinessReasonCredentialStripped", + advice: "share.readinessAdviceCredential", + } as const; + case "auth-required": + return { + reason: "share.readinessReasonAuthRequired", + advice: "share.readinessAdviceCredential", + } as const; + case "cors": + return { reason: "share.readinessReasonCors", advice: "share.readinessAdviceCors" } as const; + case "not-found": + return { + reason: "share.readinessReasonNotFound", + advice: "share.readinessAdviceNotFound", + } as const; + case "local-file": + return { + reason: "share.readinessReasonLocalFile", + advice: "share.readinessAdviceLocal", + } as const; + case "private-host": + return { + reason: "share.readinessReasonPrivateHost", + advice: "share.readinessAdviceLocal", + } as const; + case "no-source": + return { + reason: "share.readinessReasonNoSource", + advice: "share.readinessAdviceLocal", + } as const; + default: + return { reason: "share.readinessReasonUnchecked", advice: null } as const; + } +} + export function ShareProjectDialog({ open, onOpenChange, @@ -75,9 +140,14 @@ export function ShareProjectDialog({ const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); const [redactedCount, setRedactedCount] = useState(0); + const [readiness, setReadiness] = useState(null); + const [readinessState, setReadinessState] = useState<"idle" | "checking" | "failed">("idle"); const abortRef = useRef(null); const copyTimeoutRef = useRef(null); + const hasToken = shareToken.trim().length > 0; + const titleValid = isShareableTitle(title); + // Reset transient state whenever the dialog is (re)opened so a prior result or // error never lingers into a new share. Seed the title from the current // project name, but leave it blank when the project still has its default @@ -98,6 +168,48 @@ export function ShareProjectDialog({ } }, [open, currentTitle]); + // Pre-flight the project's data sources when the dialog opens, so the author + // learns that a layer will be empty for everyone else *before* the upload + // rather than when a recipient tells them (if they tell them). + // + // Advisory only: it never gates the Share button. An author sharing an + // intranet map with intranet colleagues is doing the right thing. + useEffect(() => { + if (!open || !hasToken) return; + const controller = new AbortController(); + setReadinessState("checking"); + setReadiness(null); + // Read the live layers once rather than subscribing: the dialog is modal, + // so the snapshot it opens on is the project that will be uploaded. + const state = useAppStore.getState(); + void checkShareReadiness( + { + layers: state.layers, + basemapStyleUrl: state.basemapVisible ? state.basemapStyleUrl : null, + pluginManifestUrls: state.projectPlugins?.manifestUrls ?? [], + // The publish path embeds these layers' features, so their local origin + // costs the recipient nothing. Taken from the same predicate that path + // uses so the two cannot drift. + embeddedLayerIds: new Set( + state.layers.filter(isEmbeddableLocalVectorLayer).map((layer) => layer.id), + ), + basemapLabel: t("share.readinessBasemapLabel"), + pluginLabel: t("share.readinessPluginLabel"), + }, + { signal: controller.signal }, + ) + .then((report) => { + if (controller.signal.aborted) return; + setReadiness(report); + setReadinessState("idle"); + }) + .catch(() => { + if (controller.signal.aborted) return; + setReadinessState("failed"); + }); + return () => controller.abort(); + }, [open, hasToken, t]); + // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( () => () => { @@ -108,9 +220,6 @@ export function ShareProjectDialog({ [], ); - const hasToken = shareToken.trim().length > 0; - const titleValid = isShareableTitle(title); - const handleShare = async () => { // Guard re-entry synchronously: a second click before the disabled state // renders would otherwise start a concurrent, non-idempotent upload. @@ -301,6 +410,49 @@ export function ShareProjectDialog({ + {readinessState === "checking" ? ( +

+ + {t("share.readinessChecking")} +

+ ) : readinessState === "failed" ? ( +

{t("share.readinessUnavailable")}

+ ) : readiness && readiness.problems.length > 0 ? ( +
+

+ + {t("share.readinessTitle")} +

+

{t("share.readinessNote")}

+
    + {readiness.problems.map((item) => { + const copy = readinessCopyKeys(item); + return ( +
  • +

    + {item.label} +

    +

    + {t(copy.reason)} + {copy.advice ? ` ${t(copy.advice)}` : ""} +

    +
  • + ); + })} +
+ {readiness.truncated ? ( +

+ {t("share.readinessTruncated", { count: readiness.probeCount })} +

+ ) : null} +
+ ) : readiness && readiness.items.length > 0 ? ( +

+ + {t("share.readinessAllReachable", { count: readiness.items.length })} +

+ ) : null} + {errorCode === "username-required" ? (
; + /** Label for the basemap row. Passed in so this module stays i18n-free. */ + basemapLabel?: string; + /** Label prefix for plugin manifest rows. */ + pluginLabel?: string; +} + +export interface ShareProbeOptions { + fetchImpl?: typeof fetch; + signal?: AbortSignal; + /** Per-request budget. Kept short: the dialog must not hang on a slow host. */ + timeoutMs?: number; + /** Cap on distinct targets requested. */ + maxProbes?: number; +} + +/** + * Short enough that a whole check finishes while the author is still reading + * the title field, and short enough that one dead host cannot stall the rest. + */ +export const SHARE_PROBE_TIMEOUT_MS = 6000; + +/** + * Distinct targets to request. Templates collapse to their origin and every + * target is de-duplicated, so a large project usually stays well under this; + * the cap only bites on a project that genuinely spans many hosts, where the + * remainder is reported as unchecked rather than silently dropped. + */ +export const SHARE_MAX_PROBES = 16; + +/** Worst first. Drives both the aggregate verdict and the report ordering. */ +const STATUS_SEVERITY: Record = { + local: 5, + credentialed: 4, + missing: 3, + blocked: 2, + unchecked: 1, + reachable: 0, +}; + +const MAX_FIELD_SCAN_DEPTH = 6; + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Whether a credential-named field actually holds something. */ +function isPopulated(value: unknown): boolean { + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + if (isPlainObject(value)) return Object.keys(value).length > 0; + return value !== null && value !== undefined; +} + +/** + * Whether a layer's configuration carries a populated credential field. Those + * fields are removed by `redactProjectCredentials` on the way out, so whatever + * they unlock is unavailable to the recipient even though the URL survives + * intact. An empty `headers: {}` is skipped: redaction drops it too, but it + * unlocks nothing, and warning about it would be noise. + */ +function hasCredentialField(value: unknown, depth = 0): boolean { + if (depth >= MAX_FIELD_SCAN_DEPTH) return false; + if (Array.isArray(value)) return value.some((item) => hasCredentialField(item, depth + 1)); + if (!isPlainObject(value)) return false; + for (const [key, nested] of Object.entries(value)) { + if (isCredentialFieldName(key) && isPopulated(nested)) return true; + if (hasCredentialField(nested, depth + 1)) return true; + } + return false; +} + +/** + * Whether a hostname only resolves on the author's machine or network. + * + * Covers loopback, the RFC 1918 and link-local ranges, IPv6 unique-local and + * link-local literals, the reserved intranet suffixes, and a bare single-label + * hostname (`gis-server`), which by definition needs the author's search + * domain to resolve. + */ +export function isPrivateHostname(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + if (host === "") return false; + if (host === "localhost" || host.endsWith(".localhost")) return true; + if (host === "::1" || host === "0.0.0.0") return true; + if ( + host.endsWith(".local") || + host.endsWith(".internal") || + host.endsWith(".intranet") || + host.endsWith(".home.arpa") + ) { + return true; + } + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (ipv4) { + const first = Number(ipv4[1]); + const second = Number(ipv4[2]); + if (first === 10 || first === 127) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 168) return true; + if (first === 169 && second === 254) return true; + return false; + } + // Only an IPv6 literal can carry these prefixes; a registered domain may + // legitimately start with "fd" or "fe80". + if (host.includes(":")) { + return /^f[cd][0-9a-f]{0,2}:/.test(host) || host.startsWith("fe80:"); + } + // A single-label name has no public DNS answer. + return !host.includes("."); +} + +/** Whether a URL still holds a tile/service placeholder such as `{z}`. */ +function isTemplateUrl(url: string): boolean { + return /\{[a-z0-9_-]+\}/i.test(url); +} + +/** + * What to actually request for a reference. + * + * A tile template cannot be fetched literally, and substituting a nominal + * `0/0/0` tile would 404 on any service whose data starts deeper, reporting a + * healthy basemap as missing. The origin answers the questions this check + * actually asks anyway: is the host up, does it send cross-origin headers, does + * it demand a credential. Collapsing to the origin is also what makes the probe + * budget hold for a project with dozens of tile layers on one host. + */ +export function probeTargetFor(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return isTemplateUrl(url) ? parsed.origin : parsed.toString(); +} + +interface Classification { + status: ShareSourceStatus; + reason: ShareSourceReason; + probeUrl: string | null; +} + +/** + * Settle what can be settled from the reference alone. Returns null for a + * reference that carries no information for a recipient (an inline `data:` + * payload, an app-relative path), which the caller drops rather than reports. + */ +function classifyReference(url: string): Classification | null { + const value = url.trim(); + if (value === "") return null; + // Inline payloads travel inside the project file; nothing to check. + if (value.startsWith("data:")) return null; + // A blob URL is this session's copy of a file the recipient does not have. + if (value.startsWith("blob:")) { + return { status: "local", reason: "local-file", probeUrl: null }; + } + if (value.startsWith("file://") || isAbsoluteFilesystemPath(value)) { + return { status: "local", reason: "local-file", probeUrl: null }; + } + if (!/^https?:\/\//i.test(value)) { + // A relative reference resolves against wherever the project is opened, so + // it is neither obviously broken nor checkable. Say nothing about it. + return null; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return null; + } + if (isPrivateHostname(parsed.hostname)) { + return { status: "local", reason: "private-host", probeUrl: null }; + } + // The upload strips these, so the recipient gets the URL without the secret. + // Probing would only confirm what the redaction rules already guarantee. + if (redactUrlCredentials(value) !== value) { + return { status: "credentialed", reason: "credential-stripped", probeUrl: null }; + } + if (isGooglePhotorealisticTilesetUrl(value)) { + // The key rides in a request header that is stripped before persisting, so + // the tileset is authored-working and recipient-broken by design. + return { status: "credentialed", reason: "credential-stripped", probeUrl: null }; + } + return { status: "unchecked", reason: "ok", probeUrl: probeTargetFor(value) }; +} + +/** Every place a layer can hide a reference a renderer will actually fetch. */ +function layerReferences(layer: GeoLibreLayer): { field: string; url: string }[] { + const source = layer.source ?? {}; + const metadata = layer.metadata ?? {}; + const found: { field: string; url: string }[] = []; + const push = (field: string, value: unknown) => { + if (nonEmptyString(value)) found.push({ field, url: value.trim() }); + }; + + push("source.url", source.url); + // `data` is either a URL or an inline FeatureCollection; only the former is a + // reference, and the latter is already excluded by the string check. + push("source.data", source.data); + push("source.baseUrl", source.baseUrl); + push("source.arcgisQueryUrl", source.arcgisQueryUrl); + if (Array.isArray(source.tiles)) { + source.tiles.forEach((tile, index) => push(`source.tiles[${index}]`, tile)); + } + if (Array.isArray(source.urls)) { + source.urls.forEach((entry, index) => push(`source.urls[${index}]`, entry)); + } + // The pre-resolution template, and the source of truth on reopen for an XYZ + // layer whose `source.url` was rewritten this session. + push("metadata.originalUrl", metadata.originalUrl); + push("metadata.tileUrl", metadata.tileUrl); + push("metadata.localFilePath", metadata.localFilePath); + push("metadata.localBytesUrl", metadata.localBytesUrl); + push("layer.sourcePath", layer.sourcePath); + + // De-duplicate: an XYZ layer commonly repeats one template across `source.url`, + // `source.tiles[0]`, and `metadata.originalUrl`, and reporting it three times + // would bury the layers that actually differ. + const seen = new Set(); + return found.filter((entry) => { + if (seen.has(entry.url)) return false; + seen.add(entry.url); + return true; + }); +} + +/** Whether the layer's features travel inside the project file. */ +function carriesOwnData(layer: GeoLibreLayer, embeddedLayerIds?: ReadonlySet): boolean { + if (embeddedLayerIds?.has(layer.id)) return true; + if (layer.geojson) return true; + const metadata = layer.metadata ?? {}; + if (metadata.embeddedGeoJSON) return true; + return isPlainObject((layer.source ?? {}).data); +} + +/** + * Walk the project and bucket every reference, settling everything that does + * not need the network. References left `unchecked` with a non-null `probeUrl` + * are what {@link probeShareSources} resolves. + */ +export function collectShareSources(input: ShareReadinessInput): ShareSourceRef[] { + const refs: ShareSourceRef[] = []; + + for (const layer of input.layers) { + const embedded = carriesOwnData(layer, input.embeddedLayerIds); + const references = layerReferences(layer); + if (embedded) { + // Its data ships with the project, so whatever it also points at is not + // what a recipient will render. + continue; + } + if (references.length === 0) { + // A query-backed layer (PostGIS, a DuckDB SQL layer, a sidecar result) + // that neither embeds data nor names a URL resolves only where it was + // authored. + refs.push({ + layerId: layer.id, + label: layer.name, + field: "source", + url: "", + probeUrl: null, + status: "local", + reason: "no-source", + }); + continue; + } + const credentialField = hasCredentialField(layer.source) || hasCredentialField(layer.metadata); + for (const reference of references) { + const classified = classifyReference(reference.url); + if (!classified) continue; + refs.push({ + layerId: layer.id, + label: layer.name, + field: reference.field, + url: reference.url, + ...(credentialField && classified.status === "unchecked" + ? { + probeUrl: null, + status: "credentialed" as const, + reason: "credential-stripped" as const, + } + : classified), + }); + } + } + + if (nonEmptyString(input.basemapStyleUrl)) { + const classified = classifyReference(input.basemapStyleUrl); + if (classified) { + refs.push({ + layerId: null, + label: input.basemapLabel ?? "Basemap", + field: "basemapStyleUrl", + url: input.basemapStyleUrl.trim(), + ...classified, + }); + } + } + + for (const [index, manifestUrl] of (input.pluginManifestUrls ?? []).entries()) { + // Only absolute references: a bundled drop-in is served from the app itself + // and resolves wherever the project is opened. + if (!nonEmptyString(manifestUrl) || !/^https?:\/\//i.test(manifestUrl)) continue; + const classified = classifyReference(manifestUrl); + if (!classified) continue; + refs.push({ + layerId: null, + label: input.pluginLabel ?? "Plugin", + field: `plugins.manifestUrls[${index}]`, + url: manifestUrl.trim(), + ...classified, + }); + } + + return refs; +} + +type ProbeOutcome = Pick; + +/** Statuses a HEAD may reject on while the resource itself is fine over GET. */ +const RETRY_WITH_RANGED_GET = new Set([400, 403, 405, 501]); + +function outcomeForStatus(status: number): ProbeOutcome { + if (status === 401 || status === 403 || status === 407) { + return { status: "credentialed", reason: "auth-required" }; + } + if (status === 404 || status === 410) { + return { status: "missing", reason: "not-found" }; + } + if (status >= 500 || status === 429) { + // Reachable, cross-origin headers present, but the service is unwell right + // now. That is not a property of the shared project, so do not accuse it. + return { status: "unchecked", reason: "ok" }; + } + // Everything else — including a 400 from a service endpoint asked for its + // bare URL — means the host answered and the browser was allowed to read it. + return { status: "reachable", reason: "ok" }; +} + +async function probeTarget( + target: string, + fetchImpl: typeof fetch, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const request = async (method: "HEAD" | "GET"): Promise => { + const timeout = AbortSignal.timeout(timeoutMs); + return fetchImpl(target, { + method, + // Withhold the author's ambient authority: the check must see what a + // recipient sees, not what the author's cookies unlock. + credentials: "omit", + cache: "no-store", + redirect: "follow", + ...(method === "GET" ? { headers: { Range: "bytes=0-0" } } : {}), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + }; + + try { + const head = await request("HEAD"); + if (!RETRY_WITH_RANGED_GET.has(head.status)) return outcomeForStatus(head.status); + // Plenty of object stores and CDNs refuse HEAD while serving GET happily, + // so a one-byte ranged GET decides it rather than a false "needs a login". + const ranged = await request("GET"); + return outcomeForStatus(ranged.status); + } catch (error) { + const failure = classifyFetchFailure(error); + if (failure.kind === "abort") return { status: "unchecked", reason: "probe-budget" }; + if (failure.kind === "timeout") return { status: "unchecked", reason: "timeout" }; + // The browser collapses a cross-origin rejection, a TLS failure, and an + // unreachable host into one opaque error. All three mean the recipient's + // browser cannot read this, which is the verdict that matters here. + if (failure.kind === "network") return { status: "blocked", reason: "cors" }; + return { status: "unchecked", reason: "ok" }; + } +} + +/** + * Resolve the references {@link collectShareSources} left open, one request per + * distinct target, in parallel and capped. Never throws: a check that fails is + * reported as unchecked rather than blocking the share. + */ +export async function probeShareSources( + refs: readonly ShareSourceRef[], + options: ShareProbeOptions = {}, +): Promise<{ refs: ShareSourceRef[]; probeCount: number; truncated: boolean }> { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? SHARE_PROBE_TIMEOUT_MS; + const maxProbes = options.maxProbes ?? SHARE_MAX_PROBES; + + // Insertion-ordered so the budget, when it bites, keeps the sources the + // author sees first in the layer list rather than an arbitrary subset. + const targets = new Set(); + for (const ref of refs) { + if (ref.status !== "unchecked" || !ref.probeUrl) continue; + targets.add(ref.probeUrl); + } + const probed = [...targets].slice(0, maxProbes); + const truncated = targets.size > probed.length; + + const outcomes = new Map(); + if (typeof fetchImpl === "function") { + const results = await Promise.all( + probed.map((target) => probeTarget(target, fetchImpl, timeoutMs, options.signal)), + ); + probed.forEach((target, index) => outcomes.set(target, results[index])); + } + + return { + refs: refs.map((ref) => { + if (ref.status !== "unchecked" || !ref.probeUrl) return ref; + const outcome = outcomes.get(ref.probeUrl); + if (!outcome) return { ...ref, reason: "probe-budget" }; + return { ...ref, ...outcome }; + }), + probeCount: outcomes.size, + truncated, + }; +} + +/** + * Fold the per-reference verdicts into one row per layer (or project field), + * keeping the worst. A layer with three tile mirrors is one line in the dialog, + * not three. + */ +export function summarizeShareSources(refs: readonly ShareSourceRef[]): ShareReadinessItem[] { + const byOwner = new Map(); + for (const ref of refs) { + const key = ref.layerId ?? `${ref.field}:${ref.url}`; + const existing = byOwner.get(key); + const candidate: ShareReadinessItem = { + layerId: ref.layerId, + label: ref.label, + status: ref.status, + reason: ref.reason, + url: ref.url, + }; + if (!existing || STATUS_SEVERITY[candidate.status] > STATUS_SEVERITY[existing.status]) { + byOwner.set(key, candidate); + } + } + return [...byOwner.values()]; +} + +/** Collect, probe, and summarize. What the Share dialog calls. */ +export async function checkShareReadiness( + input: ShareReadinessInput, + options: ShareProbeOptions = {}, +): Promise { + const collected = collectShareSources(input); + const { refs, probeCount, truncated } = await probeShareSources(collected, options); + const items = summarizeShareSources(refs); + const problems = items + .filter((item) => item.status !== "reachable") + .sort((a, b) => STATUS_SEVERITY[b.status] - STATUS_SEVERITY[a.status]); + return { items, problems, probeCount, truncated }; +} diff --git a/docs/features.md b/docs/features.md index 56b6ba258b..2a3a693d00 100644 --- a/docs/features.md +++ b/docs/features.md @@ -200,6 +200,7 @@ kepler.gl, see the [Comparison](comparison.md). ## Projects and sharing - Project menu to create, open, save, and Save As `.geolibre.json` projects, export a project to a single standalone interactive HTML file that runs offline with no server, and a project gallery for browsing and opening shared projects with one click +- Share-readiness check in the Share dialog: before the upload, every data source the project references is classified and probed anonymously from the browser, and the ones a recipient could not load are listed with a plain-language reason and a fix, covering credential-gated services, hosts with no cross-origin headers, expired or moved links, and local or private-network sources. It informs rather than blocks. See [Projects](user-guide/projects.md#share-readiness-check) - Autosave with a browsable project history. See [Projects](user-guide/projects.md#project-history-and-crash-recovery) - Snapshots are written to local device storage a few seconds after each change settles, and listed newest first with their layer count and zoom - Restoring a snapshot is an undoable step diff --git a/docs/user-guide/projects.md b/docs/user-guide/projects.md index db08c886b5..e0600f6181 100644 --- a/docs/user-guide/projects.md +++ b/docs/user-guide/projects.md @@ -66,6 +66,17 @@ An ArcGIS Pro project can contain several maps; GeoLibre imports its first 2D ma **Project → Share...** uploads the current project to `share.geolibre.app` and returns a public URL you can send to anyone or open in the live viewer. Sharing uses a personal API token, which you set once as the **Share.GeoLibre API token** in **Settings → Environment Variables**. The shared file is the same `.geolibre.json` the app saves locally, so anyone who opens the link sees the same layers, styles, and map view. See the [Sharing & Embedding tutorial](../tutorials/sharing-embedding.md). +### Share-readiness check + +A project file is mostly references, so a project can upload cleanly and still draw nothing for the person you sent it to. When the Share dialog opens it checks the data sources the project points at and lists the ones a recipient will not be able to load, with the reason and what to do about it: + +- **Uses a credential that is removed when sharing.** Tokens and API keys are stripped from the upload, so the recipient gets the URL without the secret. Make the service public, or tell them to supply their own key. +- **A browser cannot fetch this host.** The host sends no cross-origin (CORS) headers, or it did not answer. Layers like this keep working in the desktop app, which is not subject to browser CORS, but stay empty in the browser viewer. +- **The service answered not found.** A signed URL that has expired, or a file that moved. +- **Points at a file on your machine, or at a private network address.** Local vector data is embedded in the upload automatically, but a local raster, an intranet service, or a database-backed layer only resolves where you authored it. + +The check runs in the browser, without your credentials attached, so it sees what a recipient sees. It never blocks the upload: sharing an intranet map with intranet colleagues is a normal thing to do, and the list is there to inform you, not to stop you. + ## Export as HTML **Project → Export as HTML...** writes the whole project to a single standalone HTML file that runs offline with no server. Host it anywhere, or open it straight from disk. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c127c95570..bf8cba19d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,7 @@ export { stripGoogleMapsApiKeyHeader, } from "./three-d-tiles"; export { + isCredentialFieldName, PROJECT_CREDENTIAL_FIELDS, PUBLISHABLE_PLUGIN_SETTINGS, redactCredentials, diff --git a/tests/share-readiness.test.ts b/tests/share-readiness.test.ts new file mode 100644 index 0000000000..a794dc445f --- /dev/null +++ b/tests/share-readiness.test.ts @@ -0,0 +1,452 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GeoLibreLayer } from "../packages/core/src/types"; +import { + checkShareReadiness, + collectShareSources, + isPrivateHostname, + probeShareSources, + probeTargetFor, + summarizeShareSources, +} from "../apps/geolibre-desktop/src/lib/share-readiness"; + +function layer(overrides: Partial = {}): GeoLibreLayer { + return { + id: "layer-1", + name: "Layer 1", + type: "geojson", + source: {}, + visible: true, + opacity: 1, + style: {}, + metadata: {}, + ...overrides, + } as GeoLibreLayer; +} + +/** + * Records every request so a test can assert what was (and was not) asked for, + * and answers from a target → status/throw table. + */ +function fakeFetch(routes: Record) { + const calls: { url: string; method: string; credentials?: string }[] = []; + const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ + url, + method: init?.method ?? "GET", + credentials: init?.credentials, + }); + const route = routes[url]; + if (route === undefined) throw new TypeError("Failed to fetch"); + if (route instanceof Error) throw route; + return new Response(null, { status: route }); + }) as unknown as typeof fetch; + return { fn, calls }; +} + +describe("isPrivateHostname", () => { + it("recognizes loopback, private ranges, and reserved suffixes", () => { + for (const host of [ + "localhost", + "app.localhost", + "127.0.0.1", + "10.1.2.3", + "172.16.0.9", + "172.31.255.1", + "192.168.1.10", + "169.254.10.1", + "::1", + "fd00::1", + "fe80::1", + "gis-server", + "tiles.local", + "maps.internal", + ]) { + assert.equal(isPrivateHostname(host), true, host); + } + }); + + it("leaves public hosts alone, including ones that merely look private", () => { + for (const host of [ + "tiles.openfreemap.org", + "172.32.0.1", + "172.15.0.1", + "11.0.0.1", + "192.169.1.1", + // A registered domain may start with the IPv6 unique-local prefix. + "fd-services.com", + "fe80.example.com", + ]) { + assert.equal(isPrivateHostname(host), false, host); + } + }); +}); + +describe("probeTargetFor", () => { + it("collapses a tile template to its origin", () => { + assert.equal( + probeTargetFor("https://tile.example.com/data/{z}/{x}/{y}.png"), + "https://tile.example.com", + ); + }); + + it("keeps a concrete URL intact so an expired link is still caught", () => { + assert.equal( + probeTargetFor("https://data.example.com/dem.tif"), + "https://data.example.com/dem.tif", + ); + }); + + it("returns null for a non-HTTP reference", () => { + assert.equal(probeTargetFor("/home/me/dem.tif"), null); + assert.equal(probeTargetFor("ftp://example.com/dem.tif"), null); + }); +}); + +describe("collectShareSources", () => { + it("skips a layer whose data travels inside the project", () => { + const refs = collectShareSources({ + layers: [ + layer({ geojson: { type: "FeatureCollection", features: [] } }), + layer({ id: "b", name: "B", metadata: { embeddedGeoJSON: { type: "FeatureCollection" } } }), + layer({ id: "c", name: "C", source: { url: "https://x.example.com/a.fgb" } }), + ], + embeddedLayerIds: new Set(["c"]), + }); + assert.deepEqual(refs, []); + }); + + it("flags a local path and a private host without probing them", () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "DEM", type: "cog", source: { url: "/home/me/dem.tif" } }), + layer({ + id: "b", + name: "Intranet tiles", + type: "xyz", + source: { tiles: ["http://192.168.1.20:8080/{z}/{x}/{y}.png"] }, + }), + ], + }); + assert.deepEqual( + refs.map((ref) => [ref.layerId, ref.status, ref.reason, ref.probeUrl]), + [ + ["a", "local", "local-file", null], + ["b", "local", "private-host", null], + ], + ); + }); + + it("flags a URL whose credential the upload strips", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Keyed tiles", + type: "xyz", + source: { url: "https://api.example.com/{z}/{x}/{y}.png?apiKey=secret" }, + }), + ], + }); + assert.equal(refs.length, 1); + assert.equal(refs[0].status, "credentialed"); + assert.equal(refs[0].reason, "credential-stripped"); + assert.equal(refs[0].probeUrl, null); + }); + + it("flags a layer whose configuration carries a credential field", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Private tileset", + type: "3d-tiles", + source: { + url: "https://tiles.example.com/tileset.json", + requestHeaders: { "X-Token": "abc" }, + }, + }), + ], + }); + assert.equal(refs[0].status, "credentialed"); + assert.equal(refs[0].probeUrl, null); + }); + + it("ignores an empty credential field, which unlocks nothing", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "Public tileset", + type: "3d-tiles", + source: { url: "https://tiles.example.com/tileset.json", requestHeaders: {} }, + }), + ], + }); + assert.equal(refs[0].status, "unchecked"); + assert.equal(refs[0].probeUrl, "https://tiles.example.com/tileset.json"); + }); + + it("reports a query-backed layer that names no reference at all", () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "PostGIS parcels", + type: "duckdb-query", + source: { sql: "select * from parcels" }, + metadata: { sourceKind: "sql-query" }, + }), + ], + }); + assert.equal(refs[0].status, "local"); + assert.equal(refs[0].reason, "no-source"); + }); + + it("de-duplicates one template repeated across source and metadata", () => { + const template = "https://tile.example.com/{z}/{x}/{y}.png"; + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "XYZ", + type: "xyz", + source: { url: template, tiles: [template] }, + metadata: { originalUrl: template }, + }), + ], + }); + assert.equal(refs.length, 1); + }); + + it("includes the basemap and absolute plugin manifests, not bundled ones", () => { + const refs = collectShareSources({ + layers: [], + basemapStyleUrl: "https://tiles.openfreemap.org/styles/liberty", + pluginManifestUrls: [ + "https://plugins.example.com/p/plugin.json", + "/plugins/local/plugin.json", + ], + basemapLabel: "Basemap", + pluginLabel: "Plugin", + }); + assert.deepEqual( + refs.map((ref) => ref.field), + ["basemapStyleUrl", "plugins.manifestUrls[0]"], + ); + }); + + it("says nothing about an inline data: payload", () => { + const refs = collectShareSources({ + layers: [layer({ id: "a", type: "image", source: { url: "data:image/png;base64,AAA" } })], + }); + assert.deepEqual(refs, []); + }); +}); + +describe("probeShareSources", () => { + it("probes each distinct target once, anonymously, with HEAD", async () => { + const template = "https://tile.example.com/{z}/{x}/{y}.png"; + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "xyz", source: { url: template } }), + layer({ + id: "b", + name: "B", + type: "xyz", + source: { url: "https://tile.example.com/other/{z}/{x}/{y}.png" }, + }), + ], + }); + const { fn, calls } = fakeFetch({ "https://tile.example.com": 200 }); + const result = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, "HEAD"); + assert.equal(calls[0].credentials, "omit"); + assert.equal(result.probeCount, 1); + assert.deepEqual( + result.refs.map((ref) => ref.status), + ["reachable", "reachable"], + ); + }); + + it("maps 401 to credentialed and 404 to missing", async () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "cog", source: { url: "https://a.example.com/a.tif" } }), + layer({ id: "b", name: "B", type: "cog", source: { url: "https://b.example.com/b.tif" } }), + ], + }); + const { fn } = fakeFetch({ + "https://a.example.com/a.tif": 401, + "https://b.example.com/b.tif": 404, + }); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.deepEqual( + probed.map((ref) => [ref.status, ref.reason]), + [ + ["credentialed", "auth-required"], + ["missing", "not-found"], + ], + ); + }); + + it("retries a HEAD-refusing host with a ranged GET before calling it gated", async () => { + const refs = collectShareSources({ + layers: [ + layer({ id: "a", name: "A", type: "cog", source: { url: "https://s3.example.com/a.tif" } }), + ], + }); + let first = true; + const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + if (first && init?.method === "HEAD") { + first = false; + return new Response(null, { status: 403 }); + } + return new Response(null, { status: 206 }); + }) as unknown as typeof fetch; + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "reachable"); + }); + + it("reads an opaque browser rejection as browser-blocked", async () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "A", + type: "cog", + source: { url: "https://nocors.example.com/a.tif" }, + }), + ], + }); + const { fn } = fakeFetch({}); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "blocked"); + assert.equal(probed[0].reason, "cors"); + }); + + it("does not blame the project for a 5xx", async () => { + const refs = collectShareSources({ + layers: [ + layer({ + id: "a", + name: "A", + type: "cog", + source: { url: "https://down.example.com/a.tif" }, + }), + ], + }); + const { fn } = fakeFetch({ "https://down.example.com/a.tif": 503 }); + const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); + assert.equal(probed[0].status, "unchecked"); + }); + + it("caps the probe count and reports the remainder as unchecked", async () => { + const layers = Array.from({ length: 4 }, (_unused, index) => + layer({ + id: `l${index}`, + name: `L${index}`, + type: "cog", + source: { url: `https://host${index}.example.com/a.tif` }, + }), + ); + const { fn, calls } = fakeFetch({ + "https://host0.example.com/a.tif": 200, + "https://host1.example.com/a.tif": 200, + "https://host2.example.com/a.tif": 200, + "https://host3.example.com/a.tif": 200, + }); + const result = await probeShareSources(collectShareSources({ layers }), { + fetchImpl: fn, + maxProbes: 2, + }); + assert.equal(calls.length, 2); + assert.equal(result.truncated, true); + assert.deepEqual( + result.refs.map((ref) => ref.status), + ["reachable", "reachable", "unchecked", "unchecked"], + ); + assert.equal(result.refs[3].reason, "probe-budget"); + }); +}); + +describe("summarizeShareSources", () => { + it("keeps the worst verdict per layer", () => { + const items = summarizeShareSources([ + { + layerId: "a", + label: "A", + field: "source.tiles[0]", + url: "https://ok.example.com/a", + probeUrl: null, + status: "reachable", + reason: "ok", + }, + { + layerId: "a", + label: "A", + field: "source.tiles[1]", + url: "https://bad.example.com/a", + probeUrl: null, + status: "blocked", + reason: "cors", + }, + ]); + assert.equal(items.length, 1); + assert.equal(items[0].status, "blocked"); + }); +}); + +describe("checkShareReadiness", () => { + it("orders problems worst first and leaves reachable sources out of them", async () => { + const { fn } = fakeFetch({ "https://ok.example.com/a.tif": 200 }); + const report = await checkShareReadiness( + { + layers: [ + layer({ + id: "ok", + name: "Good", + type: "cog", + source: { url: "https://ok.example.com/a.tif" }, + }), + layer({ + id: "local", + name: "Local DEM", + type: "cog", + source: { url: "/home/me/dem.tif" }, + }), + layer({ + id: "keyed", + name: "Keyed", + type: "xyz", + source: { url: "https://k.example.com/{z}/{x}/{y}.png?apiKey=s" }, + }), + ], + }, + { fetchImpl: fn }, + ); + assert.equal(report.items.length, 3); + assert.deepEqual( + report.problems.map((item) => item.layerId), + ["local", "keyed"], + ); + }); + + it("reports everything as unchecked rather than throwing when fetch is unavailable", async () => { + const original = globalThis.fetch; + // @ts-expect-error deliberately emulating a runtime with no fetch + delete globalThis.fetch; + try { + const report = await checkShareReadiness({ + layers: [layer({ id: "a", type: "cog", source: { url: "https://a.example.com/a.tif" } })], + }); + assert.equal(report.probeCount, 0); + assert.equal(report.items[0].status, "unchecked"); + } finally { + globalThis.fetch = original; + } + }); +}); From e370884624e2aeb95e455dc1fe6c806490a89bf0 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 9 Aug 2026 20:17:39 -0400 Subject: [PATCH 2/4] Address review feedback - Share one deadline across a target's HEAD attempt and its ranged-GET retry (CodeRabbit). Each attempt built its own `AbortSignal.timeout`, so a slow host that refuses HEAD could spend the budget twice and take 12s against a documented 6s per-target limit. - Assert the retry's method and `Range: bytes=0-0` header in the HEAD-refusing test (CodeRabbit). The test only checked the final status, so dropping the range header, which would have the check pull down a whole multi-gigabyte COG on every such host, would not have failed it. - Convert `share.readinessAllReachable` and `share.readinessTruncated` to i18next `_one`/`_other` plural keys across all 18 catalogs (claude-review). The manual "(s)" suffix rendered "All 1 data source(s)" and could not be fixed per language. Each locale now carries the CLDR categories it needs: the full six for Arabic, one/few/many/other for Russian, one/other for the rest, and `_other` alone for zh/ja/ko/th/id, which have a single category. --- apps/geolibre-desktop/src/i18n/locales/ar.json | 14 ++++++++++++-- apps/geolibre-desktop/src/i18n/locales/de.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/en.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/es.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/fa.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/fr.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/hi.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/id.json | 4 ++-- apps/geolibre-desktop/src/i18n/locales/it.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/ja.json | 4 ++-- apps/geolibre-desktop/src/i18n/locales/ka.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/ko.json | 4 ++-- apps/geolibre-desktop/src/i18n/locales/nl.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/pt.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/ru.json | 10 ++++++++-- apps/geolibre-desktop/src/i18n/locales/th.json | 4 ++-- apps/geolibre-desktop/src/i18n/locales/tr.json | 6 ++++-- apps/geolibre-desktop/src/i18n/locales/zh.json | 4 ++-- apps/geolibre-desktop/src/lib/share-readiness.ts | 14 +++++++++----- tests/share-readiness.test.ts | 11 +++++++++++ 20 files changed, 94 insertions(+), 41 deletions(-) diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 7529026efa..90bbc402a3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -1347,10 +1347,20 @@ "openAccountSettings": "فتح إعدادات الحساب", "readinessChecking": "جارٍ التحقق مما إذا كان المستلمون قادرين على تحميل بياناتك…", "readinessUnavailable": "تعذّر إجراء فحص مصادر البيانات.", - "readinessAllReachable": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", + "readinessAllReachable_zero": "تبدو مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", + "readinessAllReachable_one": "يبدو {{count}} مصدر بيانات قابلًا للوصول من قِبل المستلمين.", + "readinessAllReachable_two": "تبدو مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", + "readinessAllReachable_few": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", + "readinessAllReachable_many": "تبدو جميع مصادر البيانات البالغ عددها {{count}} مصدرًا قابلة للوصول من قِبل المستلمين.", + "readinessAllReachable_other": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", "readinessTitle": "قد لا تُحمَّل بعض البيانات لدى المستلمين", "readinessNote": "لا يمنع هذا المشاركة. يفتح المستلمون المشروع في متصفح، حيث قد تتصرف هذه المصادر على نحو مختلف عمّا تفعله لديك.", - "readinessTruncated": "لم يُفحص سوى أول {{count}} مصدر.", + "readinessTruncated_zero": "لم يُفحص سوى أول {{count}} من المصادر.", + "readinessTruncated_one": "لم يُفحص سوى أول {{count}} مصدر.", + "readinessTruncated_two": "لم يُفحص سوى أول {{count}} من المصادر.", + "readinessTruncated_few": "لم تُفحص سوى أول {{count}} مصادر.", + "readinessTruncated_many": "لم يُفحص سوى أول {{count}} مصدرًا.", + "readinessTruncated_other": "لم يُفحص سوى أول {{count}} مصدر.", "readinessBasemapLabel": "خريطة الأساس", "readinessPluginLabel": "إضافة", "readinessReasonCredentialStripped": "يستخدم بيانات اعتماد تُزال عند المشاركة.", diff --git a/apps/geolibre-desktop/src/i18n/locales/de.json b/apps/geolibre-desktop/src/i18n/locales/de.json index d0237098ac..2451b7b02f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/de.json +++ b/apps/geolibre-desktop/src/i18n/locales/de.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Kontoeinstellungen öffnen", "readinessChecking": "Es wird geprüft, ob Empfänger Ihre Daten laden können…", "readinessUnavailable": "Die Prüfung der Datenquellen konnte nicht ausgeführt werden.", - "readinessAllReachable": "Alle {{count}} Datenquelle(n) scheinen für Empfänger erreichbar zu sein.", + "readinessAllReachable_one": "{{count}} Datenquelle scheint für Empfänger erreichbar zu sein.", + "readinessAllReachable_other": "Alle {{count}} Datenquellen scheinen für Empfänger erreichbar zu sein.", "readinessTitle": "Einige Daten werden für Empfänger möglicherweise nicht geladen", "readinessNote": "Das verhindert das Teilen nicht. Empfänger öffnen das Projekt im Browser, wo sich diese Quellen anders verhalten können als bei Ihnen.", - "readinessTruncated": "Nur die ersten {{count}} Quelle(n) wurden geprüft.", + "readinessTruncated_one": "Nur {{count}} Quelle wurde geprüft.", + "readinessTruncated_other": "Nur die ersten {{count}} Quellen wurden geprüft.", "readinessBasemapLabel": "Hintergrundkarte", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Verwendet Anmeldedaten, die beim Teilen entfernt werden.", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index d6534eca1f..b211e942d2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1214,10 +1214,12 @@ "openAccountSettings": "Open account settings", "readinessChecking": "Checking whether recipients can load your data…", "readinessUnavailable": "The data source check could not run.", - "readinessAllReachable": "All {{count}} data source(s) look reachable for recipients.", + "readinessAllReachable_one": "{{count}} data source looks reachable for recipients.", + "readinessAllReachable_other": "All {{count}} data sources look reachable for recipients.", "readinessTitle": "Some data may not load for recipients", "readinessNote": "This does not block sharing. Recipients open the project in a browser, where these sources can behave differently than they do for you.", - "readinessTruncated": "Only the first {{count}} source(s) were checked.", + "readinessTruncated_one": "Only the first {{count}} source was checked.", + "readinessTruncated_other": "Only the first {{count}} sources were checked.", "readinessBasemapLabel": "Basemap", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Uses a credential that is removed when sharing.", diff --git a/apps/geolibre-desktop/src/i18n/locales/es.json b/apps/geolibre-desktop/src/i18n/locales/es.json index 29bd8febd7..c5ae7c76c8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/es.json +++ b/apps/geolibre-desktop/src/i18n/locales/es.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Abrir configuración de la cuenta", "readinessChecking": "Comprobando si los destinatarios pueden cargar sus datos…", "readinessUnavailable": "No se pudo ejecutar la comprobación de las fuentes de datos.", - "readinessAllReachable": "Las {{count}} fuente(s) de datos parecen accesibles para los destinatarios.", + "readinessAllReachable_one": "{{count}} fuente de datos parece accesible para los destinatarios.", + "readinessAllReachable_other": "Las {{count}} fuentes de datos parecen accesibles para los destinatarios.", "readinessTitle": "Puede que algunos datos no se carguen para los destinatarios", "readinessNote": "Esto no impide compartir. Los destinatarios abren el proyecto en un navegador, donde estas fuentes pueden comportarse de forma distinta a como lo hacen en su equipo.", - "readinessTruncated": "Solo se comprobaron las primeras {{count}} fuente(s).", + "readinessTruncated_one": "Solo se comprobó {{count}} fuente.", + "readinessTruncated_other": "Solo se comprobaron las primeras {{count}} fuentes.", "readinessBasemapLabel": "Mapa base", "readinessPluginLabel": "Complemento", "readinessReasonCredentialStripped": "Usa una credencial que se elimina al compartir.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index e77760be0a..b9483b67ae 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -1208,10 +1208,12 @@ "openAccountSettings": "باز کردن تنظیمات حساب", "readinessChecking": "در حال بررسی اینکه گیرندگان می‌توانند داده‌های شما را بارگیری کنند یا نه…", "readinessUnavailable": "بررسی منابع داده انجام نشد.", - "readinessAllReachable": "هر {{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسد.", + "readinessAllReachable_one": "{{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسد.", + "readinessAllReachable_other": "هر {{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسد.", "readinessTitle": "ممکن است برخی داده‌ها برای گیرندگان بارگیری نشوند", "readinessNote": "این کار جلوی هم‌رسانی را نمی‌گیرد. گیرندگان پروژه را در یک مرورگر باز می‌کنند، جایی که این منابع می‌توانند رفتاری متفاوت از آنچه نزد شما دارند نشان دهند.", - "readinessTruncated": "تنها {{count}} منبع نخست بررسی شدند.", + "readinessTruncated_one": "تنها {{count}} منبع نخست بررسی شد.", + "readinessTruncated_other": "تنها {{count}} منبع نخست بررسی شدند.", "readinessBasemapLabel": "نقشهٔ پایه", "readinessPluginLabel": "افزونه", "readinessReasonCredentialStripped": "از اعتبارنامه‌ای استفاده می‌کند که هنگام هم‌رسانی حذف می‌شود.", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index db33a6298d..c0ebf3a5c8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Ouvrir les paramètres du compte", "readinessChecking": "Vérification que les destinataires peuvent charger vos données…", "readinessUnavailable": "La vérification des sources de données n'a pas pu être effectuée.", - "readinessAllReachable": "Les {{count}} source(s) de données semblent accessibles pour les destinataires.", + "readinessAllReachable_one": "{{count}} source de données semble accessible pour les destinataires.", + "readinessAllReachable_other": "Les {{count}} sources de données semblent accessibles pour les destinataires.", "readinessTitle": "Certaines données pourraient ne pas se charger pour les destinataires", "readinessNote": "Cela n'empêche pas le partage. Les destinataires ouvrent le projet dans un navigateur, où ces sources peuvent se comporter différemment que chez vous.", - "readinessTruncated": "Seules les {{count}} première(s) source(s) ont été vérifiées.", + "readinessTruncated_one": "Seule {{count}} source a été vérifiée.", + "readinessTruncated_other": "Seules les {{count}} premières sources ont été vérifiées.", "readinessBasemapLabel": "Fond de carte", "readinessPluginLabel": "Extension", "readinessReasonCredentialStripped": "Utilise des identifiants qui sont retirés lors du partage.", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index e77d632819..7afd247ed1 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "खाता सेटिंग्स खोलें", "readinessChecking": "जाँचा जा रहा है कि प्राप्तकर्ता आपका डेटा लोड कर पाएँगे या नहीं…", "readinessUnavailable": "डेटा स्रोत की जाँच नहीं चलाई जा सकी।", - "readinessAllReachable": "सभी {{count}} डेटा स्रोत प्राप्तकर्ताओं के लिए पहुँच योग्य लगते हैं।", + "readinessAllReachable_one": "{{count}} डेटा स्रोत प्राप्तकर्ताओं के लिए पहुँच योग्य लगता है।", + "readinessAllReachable_other": "सभी {{count}} डेटा स्रोत प्राप्तकर्ताओं के लिए पहुँच योग्य लगते हैं।", "readinessTitle": "कुछ डेटा प्राप्तकर्ताओं के लिए लोड नहीं हो सकता", "readinessNote": "इससे साझा करना नहीं रुकता। प्राप्तकर्ता प्रोजेक्ट को ब्राउज़र में खोलते हैं, जहाँ ये स्रोत आपके यहाँ से अलग व्यवहार कर सकते हैं।", - "readinessTruncated": "केवल पहले {{count}} स्रोत ही जाँचे गए।", + "readinessTruncated_one": "केवल पहला {{count}} स्रोत ही जाँचा गया।", + "readinessTruncated_other": "केवल पहले {{count}} स्रोत ही जाँचे गए।", "readinessBasemapLabel": "बेसमैप", "readinessPluginLabel": "प्लगइन", "readinessReasonCredentialStripped": "ऐसा क्रेडेंशियल उपयोग करता है जो साझा करते समय हटा दिया जाता है।", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index cc66f9a10d..2d81fba215 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -1172,10 +1172,10 @@ "openAccountSettings": "Buka pengaturan akun", "readinessChecking": "Memeriksa apakah penerima dapat memuat data Anda…", "readinessUnavailable": "Pemeriksaan sumber data tidak dapat dijalankan.", - "readinessAllReachable": "Semua {{count}} sumber data tampaknya dapat dijangkau oleh penerima.", + "readinessAllReachable_other": "Semua {{count}} sumber data tampaknya dapat dijangkau oleh penerima.", "readinessTitle": "Sebagian data mungkin tidak dimuat untuk penerima", "readinessNote": "Ini tidak menghalangi pembagian. Penerima membuka proyek di browser, tempat sumber-sumber ini dapat berperilaku berbeda dibandingkan pada perangkat Anda.", - "readinessTruncated": "Hanya {{count}} sumber pertama yang diperiksa.", + "readinessTruncated_other": "Hanya {{count}} sumber pertama yang diperiksa.", "readinessBasemapLabel": "Peta dasar", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Menggunakan kredensial yang dihapus saat dibagikan.", diff --git a/apps/geolibre-desktop/src/i18n/locales/it.json b/apps/geolibre-desktop/src/i18n/locales/it.json index a2eea2680b..4398a9495e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/it.json +++ b/apps/geolibre-desktop/src/i18n/locales/it.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Apri impostazioni account", "readinessChecking": "Verifica se i destinatari possono caricare i tuoi dati…", "readinessUnavailable": "Non è stato possibile eseguire il controllo delle sorgenti dati.", - "readinessAllReachable": "Le {{count}} sorgenti dati sembrano raggiungibili per i destinatari.", + "readinessAllReachable_one": "{{count}} sorgente dati sembra raggiungibile per i destinatari.", + "readinessAllReachable_other": "Le {{count}} sorgenti dati sembrano raggiungibili per i destinatari.", "readinessTitle": "Alcuni dati potrebbero non caricarsi per i destinatari", "readinessNote": "Questo non impedisce la condivisione. I destinatari aprono il progetto in un browser, dove queste sorgenti possono comportarsi diversamente rispetto a te.", - "readinessTruncated": "Sono state controllate solo le prime {{count}} sorgenti.", + "readinessTruncated_one": "È stata controllata solo {{count}} sorgente.", + "readinessTruncated_other": "Sono state controllate solo le prime {{count}} sorgenti.", "readinessBasemapLabel": "Mappa di base", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Usa una credenziale che viene rimossa durante la condivisione.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index f3ee404b49..0562cd492e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -1172,10 +1172,10 @@ "openAccountSettings": "アカウント設定を開く", "readinessChecking": "受信者があなたのデータを読み込めるか確認しています…", "readinessUnavailable": "データソースの確認を実行できませんでした。", - "readinessAllReachable": "{{count}} 個のデータソースはすべて受信者から到達できるようです。", + "readinessAllReachable_other": "{{count}} 個のデータソースはすべて受信者から到達できるようです。", "readinessTitle": "一部のデータは受信者側で読み込めない可能性があります", "readinessNote": "これによって共有がブロックされることはありません。受信者はブラウザでプロジェクトを開くため、これらのソースの動作はお手元とは異なる場合があります。", - "readinessTruncated": "最初の {{count}} 個のソースのみを確認しました。", + "readinessTruncated_other": "最初の {{count}} 個のソースのみを確認しました。", "readinessBasemapLabel": "ベースマップ", "readinessPluginLabel": "プラグイン", "readinessReasonCredentialStripped": "共有時に削除される認証情報を使用しています。", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index 303788178d..f3fc5f9967 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "ანგარიშის პარამეტრების გახსნა", "readinessChecking": "მოწმდება, შეძლებენ თუ არა მიმღებები თქვენი მონაცემების ჩატვირთვას…", "readinessUnavailable": "მონაცემთა წყაროების შემოწმება ვერ შესრულდა.", - "readinessAllReachable": "ყველა {{count}} მონაცემთა წყარო მიმღებებისთვის ხელმისაწვდომად გამოიყურება.", + "readinessAllReachable_one": "{{count}} მონაცემთა წყარო მიმღებებისთვის ხელმისაწვდომად გამოიყურება.", + "readinessAllReachable_other": "ყველა {{count}} მონაცემთა წყარო მიმღებებისთვის ხელმისაწვდომად გამოიყურება.", "readinessTitle": "ზოგიერთი მონაცემი შესაძლოა მიმღებებთან არ ჩაიტვირთოს", "readinessNote": "ეს არ აბრკოლებს გაზიარებას. მიმღებები პროექტს ბრაუზერში ხსნიან, სადაც ეს წყაროები შეიძლება თქვენთან შედარებით სხვაგვარად მოიქცნენ.", - "readinessTruncated": "შემოწმდა მხოლოდ პირველი {{count}} წყარო.", + "readinessTruncated_one": "შემოწმდა მხოლოდ პირველი {{count}} წყარო.", + "readinessTruncated_other": "შემოწმდა მხოლოდ პირველი {{count}} წყარო.", "readinessBasemapLabel": "ბაზური რუკა", "readinessPluginLabel": "პლაგინი", "readinessReasonCredentialStripped": "იყენებს ავტორიზაციის მონაცემებს, რომლებიც გაზიარებისას იშლება.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ko.json b/apps/geolibre-desktop/src/i18n/locales/ko.json index 1e379ca27a..34d29ca1ba 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ko.json +++ b/apps/geolibre-desktop/src/i18n/locales/ko.json @@ -1172,10 +1172,10 @@ "openAccountSettings": "계정 설정 열기", "readinessChecking": "수신자가 데이터를 불러올 수 있는지 확인하는 중…", "readinessUnavailable": "데이터 소스 확인을 실행할 수 없습니다.", - "readinessAllReachable": "데이터 소스 {{count}}개 모두 수신자가 접근할 수 있는 것으로 보입니다.", + "readinessAllReachable_other": "데이터 소스 {{count}}개 모두 수신자가 접근할 수 있는 것으로 보입니다.", "readinessTitle": "일부 데이터가 수신자에게 표시되지 않을 수 있습니다", "readinessNote": "이 때문에 공유가 차단되지는 않습니다. 수신자는 브라우저에서 프로젝트를 열기 때문에 이러한 소스가 본인 환경과 다르게 동작할 수 있습니다.", - "readinessTruncated": "처음 {{count}}개 소스만 확인했습니다.", + "readinessTruncated_other": "처음 {{count}}개 소스만 확인했습니다.", "readinessBasemapLabel": "배경지도", "readinessPluginLabel": "플러그인", "readinessReasonCredentialStripped": "공유할 때 제거되는 자격 증명을 사용합니다.", diff --git a/apps/geolibre-desktop/src/i18n/locales/nl.json b/apps/geolibre-desktop/src/i18n/locales/nl.json index 908716e6f1..90f3e56f78 100644 --- a/apps/geolibre-desktop/src/i18n/locales/nl.json +++ b/apps/geolibre-desktop/src/i18n/locales/nl.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Accountinstellingen openen", "readinessChecking": "Bezig met controleren of ontvangers uw gegevens kunnen laden…", "readinessUnavailable": "De controle van de gegevensbronnen kon niet worden uitgevoerd.", - "readinessAllReachable": "Alle {{count}} gegevensbron(nen) lijken bereikbaar voor ontvangers.", + "readinessAllReachable_one": "{{count}} gegevensbron lijkt bereikbaar voor ontvangers.", + "readinessAllReachable_other": "Alle {{count}} gegevensbronnen lijken bereikbaar voor ontvangers.", "readinessTitle": "Sommige gegevens worden mogelijk niet geladen voor ontvangers", "readinessNote": "Dit blokkeert het delen niet. Ontvangers openen het project in een browser, waar deze bronnen zich anders kunnen gedragen dan bij u.", - "readinessTruncated": "Alleen de eerste {{count}} bron(nen) zijn gecontroleerd.", + "readinessTruncated_one": "Er is maar {{count}} bron gecontroleerd.", + "readinessTruncated_other": "Alleen de eerste {{count}} bronnen zijn gecontroleerd.", "readinessBasemapLabel": "Achtergrondkaart", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Gebruikt aanmeldgegevens die bij het delen worden verwijderd.", diff --git a/apps/geolibre-desktop/src/i18n/locales/pt.json b/apps/geolibre-desktop/src/i18n/locales/pt.json index c297277348..c0650268c2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/pt.json +++ b/apps/geolibre-desktop/src/i18n/locales/pt.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Abrir configurações da conta", "readinessChecking": "Verificando se os destinatários conseguem carregar seus dados…", "readinessUnavailable": "Não foi possível executar a verificação das fontes de dados.", - "readinessAllReachable": "Todas as {{count}} fonte(s) de dados parecem acessíveis para os destinatários.", + "readinessAllReachable_one": "{{count}} fonte de dados parece acessível para os destinatários.", + "readinessAllReachable_other": "Todas as {{count}} fontes de dados parecem acessíveis para os destinatários.", "readinessTitle": "Alguns dados podem não carregar para os destinatários", "readinessNote": "Isso não impede o compartilhamento. Os destinatários abrem o projeto em um navegador, onde essas fontes podem se comportar de forma diferente do que para você.", - "readinessTruncated": "Somente as primeiras {{count}} fonte(s) foram verificadas.", + "readinessTruncated_one": "Somente {{count}} fonte foi verificada.", + "readinessTruncated_other": "Somente as primeiras {{count}} fontes foram verificadas.", "readinessBasemapLabel": "Mapa base", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Usa uma credencial que é removida ao compartilhar.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 1339ced7e4..6a5d92337f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -1277,10 +1277,16 @@ "openAccountSettings": "Открыть настройки учётной записи", "readinessChecking": "Проверка того, смогут ли получатели загрузить ваши данные…", "readinessUnavailable": "Не удалось выполнить проверку источников данных.", - "readinessAllReachable": "Все {{count}} источник(ов) данных выглядят доступными для получателей.", + "readinessAllReachable_one": "{{count}} источник данных выглядит доступным для получателей.", + "readinessAllReachable_few": "Все {{count}} источника данных выглядят доступными для получателей.", + "readinessAllReachable_many": "Все {{count}} источников данных выглядят доступными для получателей.", + "readinessAllReachable_other": "Все {{count}} источника данных выглядят доступными для получателей.", "readinessTitle": "Некоторые данные могут не загрузиться у получателей", "readinessNote": "Это не блокирует публикацию. Получатели открывают проект в браузере, где эти источники могут вести себя иначе, чем у вас.", - "readinessTruncated": "Проверены только первые {{count}} источник(ов).", + "readinessTruncated_one": "Проверен только первый {{count}} источник.", + "readinessTruncated_few": "Проверены только первые {{count}} источника.", + "readinessTruncated_many": "Проверены только первые {{count}} источников.", + "readinessTruncated_other": "Проверены только первые {{count}} источника.", "readinessBasemapLabel": "Базовая карта", "readinessPluginLabel": "Плагин", "readinessReasonCredentialStripped": "Использует учётные данные, которые удаляются при публикации.", diff --git a/apps/geolibre-desktop/src/i18n/locales/th.json b/apps/geolibre-desktop/src/i18n/locales/th.json index 88f57aabe7..6d2b1029d3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/th.json +++ b/apps/geolibre-desktop/src/i18n/locales/th.json @@ -1172,10 +1172,10 @@ "openAccountSettings": "เปิดการตั้งค่าบัญชี", "readinessChecking": "กำลังตรวจสอบว่าผู้รับจะโหลดข้อมูลของคุณได้หรือไม่…", "readinessUnavailable": "ไม่สามารถเรียกใช้การตรวจสอบแหล่งข้อมูลได้", - "readinessAllReachable": "แหล่งข้อมูลทั้ง {{count}} รายการดูเหมือนว่าผู้รับจะเข้าถึงได้", + "readinessAllReachable_other": "แหล่งข้อมูลทั้ง {{count}} รายการดูเหมือนว่าผู้รับจะเข้าถึงได้", "readinessTitle": "ข้อมูลบางอย่างอาจไม่โหลดสำหรับผู้รับ", "readinessNote": "เรื่องนี้ไม่ได้ขัดขวางการแชร์ ผู้รับจะเปิดโปรเจกต์ในเบราว์เซอร์ ซึ่งแหล่งข้อมูลเหล่านี้อาจทำงานต่างจากที่เป็นอยู่ในเครื่องของคุณ", - "readinessTruncated": "ตรวจสอบเฉพาะแหล่งข้อมูล {{count}} รายการแรกเท่านั้น", + "readinessTruncated_other": "ตรวจสอบเฉพาะแหล่งข้อมูล {{count}} รายการแรกเท่านั้น", "readinessBasemapLabel": "แผนที่ฐาน", "readinessPluginLabel": "ปลั๊กอิน", "readinessReasonCredentialStripped": "ใช้ข้อมูลประจำตัวที่จะถูกนำออกเมื่อแชร์", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index 737da16f48..9902fd5298 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -1207,10 +1207,12 @@ "openAccountSettings": "Hesap ayarlarını aç", "readinessChecking": "Alıcıların verilerinizi yükleyip yükleyemeyeceği denetleniyor…", "readinessUnavailable": "Veri kaynağı denetimi çalıştırılamadı.", - "readinessAllReachable": "{{count}} veri kaynağının tümü alıcılar için erişilebilir görünüyor.", + "readinessAllReachable_one": "{{count}} veri kaynağı alıcılar için erişilebilir görünüyor.", + "readinessAllReachable_other": "{{count}} veri kaynağının tümü alıcılar için erişilebilir görünüyor.", "readinessTitle": "Bazı veriler alıcılarda yüklenmeyebilir", "readinessNote": "Bu, paylaşmayı engellemez. Alıcılar projeyi bir tarayıcıda açar ve bu kaynaklar orada sizdekinden farklı davranabilir.", - "readinessTruncated": "Yalnızca ilk {{count}} kaynak denetlendi.", + "readinessTruncated_one": "Yalnızca ilk {{count}} kaynak denetlendi.", + "readinessTruncated_other": "Yalnızca ilk {{count}} kaynak denetlendi.", "readinessBasemapLabel": "Temel harita", "readinessPluginLabel": "Eklenti", "readinessReasonCredentialStripped": "Paylaşırken kaldırılan bir kimlik bilgisi kullanıyor.", diff --git a/apps/geolibre-desktop/src/i18n/locales/zh.json b/apps/geolibre-desktop/src/i18n/locales/zh.json index 4465bc42fc..2b295083a7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/zh.json +++ b/apps/geolibre-desktop/src/i18n/locales/zh.json @@ -1172,10 +1172,10 @@ "openAccountSettings": "打开账户设置", "readinessChecking": "正在检查接收者能否加载您的数据…", "readinessUnavailable": "无法运行数据源检查。", - "readinessAllReachable": "全部 {{count}} 个数据源对接收者而言似乎均可访问。", + "readinessAllReachable_other": "全部 {{count}} 个数据源对接收者而言似乎均可访问。", "readinessTitle": "部分数据可能无法为接收者加载", "readinessNote": "这不会阻止共享。接收者会在浏览器中打开项目,这些数据源在浏览器中的表现可能与在您这里不同。", - "readinessTruncated": "仅检查了前 {{count}} 个数据源。", + "readinessTruncated_other": "仅检查了前 {{count}} 个数据源。", "readinessBasemapLabel": "底图", "readinessPluginLabel": "插件", "readinessReasonCredentialStripped": "使用了共享时会被移除的凭据。", diff --git a/apps/geolibre-desktop/src/lib/share-readiness.ts b/apps/geolibre-desktop/src/lib/share-readiness.ts index fa959e787c..49af78978e 100644 --- a/apps/geolibre-desktop/src/lib/share-readiness.ts +++ b/apps/geolibre-desktop/src/lib/share-readiness.ts @@ -470,19 +470,23 @@ async function probeTarget( timeoutMs: number, signal?: AbortSignal, ): Promise { - const request = async (method: "HEAD" | "GET"): Promise => { - const timeout = AbortSignal.timeout(timeoutMs); - return fetchImpl(target, { + // One deadline for the whole target rather than one per attempt, so a slow + // host that refuses HEAD cannot spend the budget twice over. + const timeout = AbortSignal.timeout(timeoutMs); + const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout; + const request = async (method: "HEAD" | "GET"): Promise => + fetchImpl(target, { method, // Withhold the author's ambient authority: the check must see what a // recipient sees, not what the author's cookies unlock. credentials: "omit", cache: "no-store", redirect: "follow", + // One byte is enough to learn the status. Without the range, a + // HEAD-refusing host would have a whole multi-gigabyte COG pulled down. ...(method === "GET" ? { headers: { Range: "bytes=0-0" } } : {}), - signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + signal: deadline, }); - }; try { const head = await request("HEAD"); diff --git a/tests/share-readiness.test.ts b/tests/share-readiness.test.ts index a794dc445f..3bfd2f4c43 100644 --- a/tests/share-readiness.test.ts +++ b/tests/share-readiness.test.ts @@ -299,8 +299,13 @@ describe("probeShareSources", () => { ], }); let first = true; + const attempts: { method?: string; range?: string }[] = []; const fn = (async (input: RequestInfo | URL, init?: RequestInit) => { void input; + attempts.push({ + method: init?.method, + range: (init?.headers as Record | undefined)?.Range, + }); if (first && init?.method === "HEAD") { first = false; return new Response(null, { status: 403 }); @@ -309,6 +314,12 @@ describe("probeShareSources", () => { }) as unknown as typeof fetch; const { refs: probed } = await probeShareSources(refs, { fetchImpl: fn }); assert.equal(probed[0].status, "reachable"); + // The range matters as much as the method: without it, every HEAD-refusing + // host would have its whole object downloaded by the readiness check. + assert.deepEqual(attempts, [ + { method: "HEAD", range: undefined }, + { method: "GET", range: "bytes=0-0" }, + ]); }); it("reads an opaque browser rejection as browser-blocked", async () => { From a4b2bcb4ec85d126f66400b0a49b3ae39135c525 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 9 Aug 2026 20:32:42 -0400 Subject: [PATCH 3/4] Address second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the ordinal from the singular truncation message (CodeRabbit). "Only the first 1 source was checked" reads as a miscount; the `_one` form now says "Only {{count}} source was checked", and ar, fa, hi, ka, ru, and tr drop their ordinal the same way. - Make the Indonesian forms count-neutral (CodeRabbit). Indonesian has one plural category, so `_other` also renders at count 1. - Fix three wordings CodeRabbit flagged: Persian used "each" where the message means "all", Japanese made the source rather than the recipient the subject of access, and the French note used the non-standard "différemment que" comparison. - Give a caller-cancelled probe its own `aborted` reason (claude-review). It shared `probe-budget` with "past the cap" and "no fetch available", which is invisible today but misleading if the reason ever reaches diagnostics. - Recognize the RFC 6598 carrier-grade NAT range 100.64.0.0/10 as private (claude-review), so a service reachable only there is pre-classified rather than reported as a generic CORS failure. - Document the one case where `embeddedLayerIds` over-trusts the publish path (claude-review): the predicate says a layer *can* be embedded, but data the upload cannot read back is dropped, and such a layer would ship with neither a URL nor features after this check cleared it. Settling it would mean a DuckDB export of every local vector layer on dialog open, which is the cost this check exists to avoid. Declined: switching the probe to `redirect: "error"`. Left for discussion on the thread. --- .../geolibre-desktop/src/i18n/locales/ar.json | 2 +- .../geolibre-desktop/src/i18n/locales/en.json | 2 +- .../geolibre-desktop/src/i18n/locales/fa.json | 4 +-- .../geolibre-desktop/src/i18n/locales/fr.json | 2 +- .../geolibre-desktop/src/i18n/locales/hi.json | 2 +- .../geolibre-desktop/src/i18n/locales/id.json | 4 +-- .../geolibre-desktop/src/i18n/locales/ja.json | 2 +- .../geolibre-desktop/src/i18n/locales/ka.json | 2 +- .../geolibre-desktop/src/i18n/locales/ru.json | 2 +- .../geolibre-desktop/src/i18n/locales/tr.json | 2 +- .../src/lib/share-readiness.ts | 25 +++++++++++++++---- tests/share-readiness.test.ts | 6 +++++ 12 files changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/geolibre-desktop/src/i18n/locales/ar.json b/apps/geolibre-desktop/src/i18n/locales/ar.json index 9ad87ecd92..8eba15bb5f 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ar.json +++ b/apps/geolibre-desktop/src/i18n/locales/ar.json @@ -1362,7 +1362,7 @@ "readinessAllReachable_other": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.", "readinessTitle": "قد لا تُحمَّل بعض البيانات لدى المستلمين", "readinessNote": "لا يمنع هذا المشاركة. يفتح المستلمون المشروع في متصفح، حيث قد تتصرف هذه المصادر على نحو مختلف عمّا تفعله لديك.", - "readinessTruncated_one": "لم يُفحص سوى أول {{count}} مصدر.", + "readinessTruncated_one": "لم يُفحص سوى {{count}} مصدر.", "readinessTruncated_zero": "لم يُفحص سوى أول {{count}} من المصادر.", "readinessTruncated_two": "لم يُفحص سوى أول {{count}} من المصادر.", "readinessTruncated_few": "لم تُفحص سوى أول {{count}} مصادر.", diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 2b94bf76ed..8d432beaf3 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1218,7 +1218,7 @@ "readinessAllReachable_other": "All {{count}} data sources look reachable for recipients.", "readinessTitle": "Some data may not load for recipients", "readinessNote": "This does not block sharing. Recipients open the project in a browser, where these sources can behave differently than they do for you.", - "readinessTruncated_one": "Only the first {{count}} source was checked.", + "readinessTruncated_one": "Only {{count}} source was checked.", "readinessTruncated_other": "Only the first {{count}} sources were checked.", "readinessBasemapLabel": "Basemap", "readinessPluginLabel": "Plugin", diff --git a/apps/geolibre-desktop/src/i18n/locales/fa.json b/apps/geolibre-desktop/src/i18n/locales/fa.json index 5832a985fa..39cda60b09 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fa.json +++ b/apps/geolibre-desktop/src/i18n/locales/fa.json @@ -1215,10 +1215,10 @@ "readinessChecking": "در حال بررسی اینکه گیرندگان می‌توانند داده‌های شما را بارگیری کنند یا نه…", "readinessUnavailable": "بررسی منابع داده انجام نشد.", "readinessAllReachable_one": "{{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسد.", - "readinessAllReachable_other": "هر {{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسد.", + "readinessAllReachable_other": "{{count}} منبع داده برای گیرندگان در دسترس به نظر می‌رسند.", "readinessTitle": "ممکن است برخی داده‌ها برای گیرندگان بارگیری نشوند", "readinessNote": "این کار جلوی هم‌رسانی را نمی‌گیرد. گیرندگان پروژه را در یک مرورگر باز می‌کنند، جایی که این منابع می‌توانند رفتاری متفاوت از آنچه نزد شما دارند نشان دهند.", - "readinessTruncated_one": "تنها {{count}} منبع نخست بررسی شد.", + "readinessTruncated_one": "تنها {{count}} منبع بررسی شد.", "readinessTruncated_other": "تنها {{count}} منبع نخست بررسی شدند.", "readinessBasemapLabel": "نقشهٔ پایه", "readinessPluginLabel": "افزونه", diff --git a/apps/geolibre-desktop/src/i18n/locales/fr.json b/apps/geolibre-desktop/src/i18n/locales/fr.json index fc3c0fc4ae..97e1dbb5b7 100644 --- a/apps/geolibre-desktop/src/i18n/locales/fr.json +++ b/apps/geolibre-desktop/src/i18n/locales/fr.json @@ -1217,7 +1217,7 @@ "readinessAllReachable_one": "{{count}} source de données semble accessible pour les destinataires.", "readinessAllReachable_other": "Les {{count}} sources de données semblent accessibles pour les destinataires.", "readinessTitle": "Certaines données pourraient ne pas se charger pour les destinataires", - "readinessNote": "Cela n'empêche pas le partage. Les destinataires ouvrent le projet dans un navigateur, où ces sources peuvent se comporter différemment que chez vous.", + "readinessNote": "Cela n'empêche pas le partage. Les destinataires ouvrent le projet dans un navigateur, où ces sources peuvent se comporter différemment de ce qu'elles font chez vous.", "readinessTruncated_one": "Seule {{count}} source a été vérifiée.", "readinessTruncated_other": "Seules les {{count}} premières sources ont été vérifiées.", "readinessBasemapLabel": "Fond de carte", diff --git a/apps/geolibre-desktop/src/i18n/locales/hi.json b/apps/geolibre-desktop/src/i18n/locales/hi.json index 9383034eef..4b41b630d2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/hi.json +++ b/apps/geolibre-desktop/src/i18n/locales/hi.json @@ -1218,7 +1218,7 @@ "readinessAllReachable_other": "सभी {{count}} डेटा स्रोत प्राप्तकर्ताओं के लिए पहुँच योग्य लगते हैं।", "readinessTitle": "कुछ डेटा प्राप्तकर्ताओं के लिए लोड नहीं हो सकता", "readinessNote": "इससे साझा करना नहीं रुकता। प्राप्तकर्ता प्रोजेक्ट को ब्राउज़र में खोलते हैं, जहाँ ये स्रोत आपके यहाँ से अलग व्यवहार कर सकते हैं।", - "readinessTruncated_one": "केवल पहला {{count}} स्रोत ही जाँचा गया।", + "readinessTruncated_one": "केवल {{count}} स्रोत ही जाँचा गया।", "readinessTruncated_other": "केवल पहले {{count}} स्रोत ही जाँचे गए।", "readinessBasemapLabel": "बेसमैप", "readinessPluginLabel": "प्लगइन", diff --git a/apps/geolibre-desktop/src/i18n/locales/id.json b/apps/geolibre-desktop/src/i18n/locales/id.json index 7cbf35d3af..f4b22fbf32 100644 --- a/apps/geolibre-desktop/src/i18n/locales/id.json +++ b/apps/geolibre-desktop/src/i18n/locales/id.json @@ -1179,10 +1179,10 @@ "openAccountSettings": "Buka pengaturan akun", "readinessChecking": "Memeriksa apakah penerima dapat memuat data Anda…", "readinessUnavailable": "Pemeriksaan sumber data tidak dapat dijalankan.", - "readinessAllReachable_other": "Semua {{count}} sumber data tampaknya dapat dijangkau oleh penerima.", + "readinessAllReachable_other": "{{count}} sumber data tampaknya dapat dijangkau oleh penerima.", "readinessTitle": "Sebagian data mungkin tidak dimuat untuk penerima", "readinessNote": "Ini tidak menghalangi pembagian. Penerima membuka proyek di browser, tempat sumber-sumber ini dapat berperilaku berbeda dibandingkan pada perangkat Anda.", - "readinessTruncated_other": "Hanya {{count}} sumber pertama yang diperiksa.", + "readinessTruncated_other": "Hanya {{count}} sumber awal yang diperiksa.", "readinessBasemapLabel": "Peta dasar", "readinessPluginLabel": "Plugin", "readinessReasonCredentialStripped": "Menggunakan kredensial yang dihapus saat dibagikan.", diff --git a/apps/geolibre-desktop/src/i18n/locales/ja.json b/apps/geolibre-desktop/src/i18n/locales/ja.json index 9b7ee0f987..cc4fc21e5e 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ja.json +++ b/apps/geolibre-desktop/src/i18n/locales/ja.json @@ -1179,7 +1179,7 @@ "openAccountSettings": "アカウント設定を開く", "readinessChecking": "受信者があなたのデータを読み込めるか確認しています…", "readinessUnavailable": "データソースの確認を実行できませんでした。", - "readinessAllReachable_other": "{{count}} 個のデータソースはすべて受信者から到達できるようです。", + "readinessAllReachable_other": "{{count}} 個のデータソースに受信者がアクセスできるようです。", "readinessTitle": "一部のデータは受信者側で読み込めない可能性があります", "readinessNote": "これによって共有がブロックされることはありません。受信者はブラウザでプロジェクトを開くため、これらのソースの動作はお手元とは異なる場合があります。", "readinessTruncated_other": "最初の {{count}} 個のソースのみを確認しました。", diff --git a/apps/geolibre-desktop/src/i18n/locales/ka.json b/apps/geolibre-desktop/src/i18n/locales/ka.json index c7e529aace..e6ebf401e8 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ka.json +++ b/apps/geolibre-desktop/src/i18n/locales/ka.json @@ -1218,7 +1218,7 @@ "readinessAllReachable_other": "ყველა {{count}} მონაცემთა წყარო მიმღებებისთვის ხელმისაწვდომად გამოიყურება.", "readinessTitle": "ზოგიერთი მონაცემი შესაძლოა მიმღებებთან არ ჩაიტვირთოს", "readinessNote": "ეს არ აბრკოლებს გაზიარებას. მიმღებები პროექტს ბრაუზერში ხსნიან, სადაც ეს წყაროები შეიძლება თქვენთან შედარებით სხვაგვარად მოიქცნენ.", - "readinessTruncated_one": "შემოწმდა მხოლოდ პირველი {{count}} წყარო.", + "readinessTruncated_one": "შემოწმდა მხოლოდ {{count}} წყარო.", "readinessTruncated_other": "შემოწმდა მხოლოდ პირველი {{count}} წყარო.", "readinessBasemapLabel": "ბაზური რუკა", "readinessPluginLabel": "პლაგინი", diff --git a/apps/geolibre-desktop/src/i18n/locales/ru.json b/apps/geolibre-desktop/src/i18n/locales/ru.json index 5d229888b9..f1e0f70143 100644 --- a/apps/geolibre-desktop/src/i18n/locales/ru.json +++ b/apps/geolibre-desktop/src/i18n/locales/ru.json @@ -1290,7 +1290,7 @@ "readinessAllReachable_other": "Все {{count}} источника данных выглядят доступными для получателей.", "readinessTitle": "Некоторые данные могут не загрузиться у получателей", "readinessNote": "Это не блокирует публикацию. Получатели открывают проект в браузере, где эти источники могут вести себя иначе, чем у вас.", - "readinessTruncated_one": "Проверен только первый {{count}} источник.", + "readinessTruncated_one": "Проверен только {{count}} источник.", "readinessTruncated_few": "Проверены только первые {{count}} источника.", "readinessTruncated_many": "Проверены только первые {{count}} источников.", "readinessTruncated_other": "Проверены только первые {{count}} источника.", diff --git a/apps/geolibre-desktop/src/i18n/locales/tr.json b/apps/geolibre-desktop/src/i18n/locales/tr.json index a8d5263395..a3b92bc723 100644 --- a/apps/geolibre-desktop/src/i18n/locales/tr.json +++ b/apps/geolibre-desktop/src/i18n/locales/tr.json @@ -1218,7 +1218,7 @@ "readinessAllReachable_other": "{{count}} veri kaynağının tümü alıcılar için erişilebilir görünüyor.", "readinessTitle": "Bazı veriler alıcılarda yüklenmeyebilir", "readinessNote": "Bu, paylaşmayı engellemez. Alıcılar projeyi bir tarayıcıda açar ve bu kaynaklar orada sizdekinden farklı davranabilir.", - "readinessTruncated_one": "Yalnızca ilk {{count}} kaynak denetlendi.", + "readinessTruncated_one": "Yalnızca {{count}} kaynak denetlendi.", "readinessTruncated_other": "Yalnızca ilk {{count}} kaynak denetlendi.", "readinessBasemapLabel": "Temel harita", "readinessPluginLabel": "Eklenti", diff --git a/apps/geolibre-desktop/src/lib/share-readiness.ts b/apps/geolibre-desktop/src/lib/share-readiness.ts index 49af78978e..bcf93d0f34 100644 --- a/apps/geolibre-desktop/src/lib/share-readiness.ts +++ b/apps/geolibre-desktop/src/lib/share-readiness.ts @@ -65,6 +65,9 @@ export type ShareSourceReason = /** The layer has no reference a recipient could resolve at all. */ | "no-source" | "timeout" + /** The caller cancelled, e.g. the dialog closed mid-check. */ + | "aborted" + /** Never requested: past the probe cap, or no `fetch` to request with. */ | "probe-budget"; /** One reference found in the project, before or after probing. */ @@ -117,6 +120,15 @@ export interface ShareReadinessInput { * Layers whose data the publish path embeds, so their local origin is not a * problem for a recipient. Supplied by the caller from the same predicate the * publish path uses, rather than re-derived here. + * + * Known limitation: the predicate says the layer *can* be embedded, not that + * the upload's `materializeEmbeddableVectorLayers` will succeed in reading it + * back. Data it cannot read (a streamed GeoParquet, or a control that has not + * been created yet) is dropped from the upload, and such a layer would ship + * with neither a URL nor features while this check has already cleared it. + * Settling that would mean exporting every local vector layer through DuckDB + * on dialog open, which is the cost the check is designed to avoid, so the + * narrow unreadable-local-data case is accepted as a false negative. */ embeddedLayerIds?: ReadonlySet; /** Label for the basemap row. Passed in so this module stays i18n-free. */ @@ -197,10 +209,11 @@ function hasCredentialField(value: unknown, depth = 0): boolean { /** * Whether a hostname only resolves on the author's machine or network. * - * Covers loopback, the RFC 1918 and link-local ranges, IPv6 unique-local and - * link-local literals, the reserved intranet suffixes, and a bare single-label - * hostname (`gis-server`), which by definition needs the author's search - * domain to resolve. + * Covers loopback, the RFC 1918 and link-local ranges, the RFC 6598 + * carrier-grade NAT range that some corporate networks use for internal + * addressing, IPv6 unique-local and link-local literals, the reserved intranet + * suffixes, and a bare single-label hostname (`gis-server`), which by + * definition needs the author's search domain to resolve. */ export function isPrivateHostname(hostname: string): boolean { const host = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); @@ -223,6 +236,8 @@ export function isPrivateHostname(hostname: string): boolean { if (first === 172 && second >= 16 && second <= 31) return true; if (first === 192 && second === 168) return true; if (first === 169 && second === 254) return true; + // RFC 6598, 100.64.0.0/10. + if (first === 100 && second >= 64 && second <= 127) return true; return false; } // Only an IPv6 literal can carry these prefixes; a registered domain may @@ -497,7 +512,7 @@ async function probeTarget( return outcomeForStatus(ranged.status); } catch (error) { const failure = classifyFetchFailure(error); - if (failure.kind === "abort") return { status: "unchecked", reason: "probe-budget" }; + if (failure.kind === "abort") return { status: "unchecked", reason: "aborted" }; if (failure.kind === "timeout") return { status: "unchecked", reason: "timeout" }; // The browser collapses a cross-origin rejection, a TLS failure, and an // unreachable host into one opaque error. All three mean the recipient's diff --git a/tests/share-readiness.test.ts b/tests/share-readiness.test.ts index 3bfd2f4c43..057599a93c 100644 --- a/tests/share-readiness.test.ts +++ b/tests/share-readiness.test.ts @@ -56,6 +56,9 @@ describe("isPrivateHostname", () => { "172.31.255.1", "192.168.1.10", "169.254.10.1", + // RFC 6598 carrier-grade NAT. + "100.64.0.1", + "100.127.255.254", "::1", "fd00::1", "fe80::1", @@ -74,6 +77,9 @@ describe("isPrivateHostname", () => { "172.15.0.1", "11.0.0.1", "192.169.1.1", + // Just outside 100.64.0.0/10 on either side. + "100.63.255.255", + "100.128.0.1", // A registered domain may start with the IPv6 unique-local prefix. "fd-services.com", "fe80.example.com", From f172cfce702e24c40b326897ec6e1ba15230c913 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sun, 9 Aug 2026 20:47:27 -0400 Subject: [PATCH 4/4] Address third review round - Scan for credential fields exactly as deep as the redaction pass does (claude-review). `MAX_FIELD_SCAN_DEPTH` was 6 while `MAX_REDACT_DEPTH` is 12, so a credential nested between those depths would be stripped by the upload but reported as reachable here. `MAX_REDACT_DEPTH` is now exported from core and used directly, since the point of this module is to reuse the redaction rules rather than keep a second copy of them. - Stop re-probing when the UI language changes (claude-review). The effect depended on `t` only to label the two project-level rows, so react-i18next handing out a new `t` on a language switch re-issued every network probe. The check no longer takes labels at all: it reports project-level rows with an empty `label` plus their `field`, and the dialog translates one at render time. The effect now depends on `[open, hasToken]`. Verified in the browser that the basemap row still labels correctly, and localizes (`Hintergrundkarte` under `?lang=de`). --- .../components/layout/ShareProjectDialog.tsx | 24 ++++++++++++----- .../src/lib/share-readiness.ts | 26 ++++++++++++------- packages/core/src/credentials.ts | 8 +++++- packages/core/src/index.ts | 1 + tests/share-readiness.test.ts | 8 ++++-- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index 133a0f07b6..48fc827a6e 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -11,6 +11,7 @@ import { Label, Select, } from "@geolibre/ui"; +import type { TFunction } from "i18next"; import { Check, CircleCheck, @@ -70,6 +71,19 @@ function accountSettingsUrl(): string | null { return base ? `${base}/settings` : null; } +/** + * The row's heading: a layer's own name, or a translated label for the two + * project-level references (the basemap style and a plugin manifest), which the + * check reports without a name of their own so it never has to be handed the + * translation function. + */ +function readinessLabel(item: ShareReadinessItem, t: TFunction): string { + if (item.label) return item.label; + return item.field === "basemapStyleUrl" + ? t("share.readinessBasemapLabel") + : t("share.readinessPluginLabel"); +} + /** * The plain-language reason shown for a verdict, and what the author can do * about it. Keyed off the reason rather than the status so an unreachable host @@ -193,8 +207,6 @@ export function ShareProjectDialog({ embeddedLayerIds: new Set( state.layers.filter(isEmbeddableLocalVectorLayer).map((layer) => layer.id), ), - basemapLabel: t("share.readinessBasemapLabel"), - pluginLabel: t("share.readinessPluginLabel"), }, { signal: controller.signal }, ) @@ -208,7 +220,7 @@ export function ShareProjectDialog({ setReadinessState("failed"); }); return () => controller.abort(); - }, [open, hasToken, t]); + }, [open, hasToken]); // Cancel a pending "copied" reset if the dialog unmounts mid-window. useEffect( @@ -428,9 +440,9 @@ export function ShareProjectDialog({ {readiness.problems.map((item) => { const copy = readinessCopyKeys(item); return ( -
  • -

    - {item.label} +

  • +

    + {readinessLabel(item, t)}

    {t(copy.reason)} diff --git a/apps/geolibre-desktop/src/lib/share-readiness.ts b/apps/geolibre-desktop/src/lib/share-readiness.ts index bcf93d0f34..536d6b0bb4 100644 --- a/apps/geolibre-desktop/src/lib/share-readiness.ts +++ b/apps/geolibre-desktop/src/lib/share-readiness.ts @@ -31,6 +31,7 @@ import { isAbsoluteFilesystemPath, isCredentialFieldName, isGooglePhotorealisticTilesetUrl, + MAX_REDACT_DEPTH, redactUrlCredentials, type GeoLibreLayer, } from "@geolibre/core"; @@ -74,7 +75,12 @@ export type ShareSourceReason = export interface ShareSourceRef { /** Owning layer id, or null for a project-level reference. */ layerId: string | null; - /** Display name for the row: the layer name, or a project-level label. */ + /** + * The layer's name, or empty for a project-level reference, whose label the + * UI derives from {@link ShareSourceRef.field}. Keeping the localized strings + * out of here is what lets the check run without depending on the translation + * function, so switching language never re-issues the probes. + */ label: string; /** Where in the project the reference sits, e.g. `source.tiles[0]`. */ field: string; @@ -93,7 +99,10 @@ export interface ShareSourceRef { /** One row in the dialog: a layer (or project field) and its worst verdict. */ export interface ShareReadinessItem { layerId: string | null; + /** Empty for a project-level row; see {@link ShareSourceRef.label}. */ label: string; + /** Where the reference sits, so the UI can label a project-level row. */ + field: string; status: ShareSourceStatus; reason: ShareSourceReason; /** The reference that produced the verdict, for the detail line. */ @@ -131,10 +140,6 @@ export interface ShareReadinessInput { * narrow unreadable-local-data case is accepted as a false negative. */ embeddedLayerIds?: ReadonlySet; - /** Label for the basemap row. Passed in so this module stays i18n-free. */ - basemapLabel?: string; - /** Label prefix for plugin manifest rows. */ - pluginLabel?: string; } export interface ShareProbeOptions { @@ -170,8 +175,6 @@ const STATUS_SEVERITY: Record = { reachable: 0, }; -const MAX_FIELD_SCAN_DEPTH = 6; - function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim() !== ""; } @@ -196,7 +199,9 @@ function isPopulated(value: unknown): boolean { * unlocks nothing, and warning about it would be noise. */ function hasCredentialField(value: unknown, depth = 0): boolean { - if (depth >= MAX_FIELD_SCAN_DEPTH) return false; + // Exactly as deep as the redaction pass descends, so a credential nested + // deeply enough to escape this scan but not that one cannot exist. + if (depth >= MAX_REDACT_DEPTH) return false; if (Array.isArray(value)) return value.some((item) => hasCredentialField(item, depth + 1)); if (!isPlainObject(value)) return false; for (const [key, nested] of Object.entries(value)) { @@ -431,7 +436,7 @@ export function collectShareSources(input: ShareReadinessInput): ShareSourceRef[ if (classified) { refs.push({ layerId: null, - label: input.basemapLabel ?? "Basemap", + label: "", field: "basemapStyleUrl", url: input.basemapStyleUrl.trim(), ...classified, @@ -447,7 +452,7 @@ export function collectShareSources(input: ShareReadinessInput): ShareSourceRef[ if (!classified) continue; refs.push({ layerId: null, - label: input.pluginLabel ?? "Plugin", + label: "", field: `plugins.manifestUrls[${index}]`, url: manifestUrl.trim(), ...classified, @@ -578,6 +583,7 @@ export function summarizeShareSources(refs: readonly ShareSourceRef[]): ShareRea const candidate: ShareReadinessItem = { layerId: ref.layerId, label: ref.label, + field: ref.field, status: ref.status, reason: ref.reason, url: ref.url, diff --git a/packages/core/src/credentials.ts b/packages/core/src/credentials.ts index e2031d38d8..3dbc41a1d4 100644 --- a/packages/core/src/credentials.ts +++ b/packages/core/src/credentials.ts @@ -95,7 +95,13 @@ const URL_CREDENTIAL_PARAMS = new Set( "skoid", ].map(normalizeCredentialName), ); -const MAX_REDACT_DEPTH = 12; +/** + * Depth at which the redaction pass stops descending and fails closed. + * Exported so a caller that predicts what redaction will remove (the Share + * dialog's readiness check) scans exactly as deep as this pass does, instead of + * keeping a second, shallower cap that would silently disagree. + */ +export const MAX_REDACT_DEPTH = 12; /** Whether an object key in layer/plugin configuration holds a credential. */ export function isCredentialFieldName(name: string): boolean { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bf8cba19d5..3251c50f88 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,7 @@ export { } from "./three-d-tiles"; export { isCredentialFieldName, + MAX_REDACT_DEPTH, PROJECT_CREDENTIAL_FIELDS, PUBLISHABLE_PLUGIN_SETTINGS, redactCredentials, diff --git a/tests/share-readiness.test.ts b/tests/share-readiness.test.ts index 057599a93c..358d69ffbb 100644 --- a/tests/share-readiness.test.ts +++ b/tests/share-readiness.test.ts @@ -234,13 +234,17 @@ describe("collectShareSources", () => { "https://plugins.example.com/p/plugin.json", "/plugins/local/plugin.json", ], - basemapLabel: "Basemap", - pluginLabel: "Plugin", }); assert.deepEqual( refs.map((ref) => ref.field), ["basemapStyleUrl", "plugins.manifestUrls[0]"], ); + // Project-level rows carry no label: the dialog translates one from `field`, + // so the check never needs the translation function. + assert.deepEqual( + refs.map((ref) => ref.label), + ["", ""], + ); }); it("says nothing about an inline data: payload", () => {