diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index 653fc626c..63adcb372 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -1,5 +1,6 @@ import { DEFAULT_PROJECT_NAME, + excludeHiddenFieldsFromProject, redactProjectCredentials, serializeProject, useAppStore, @@ -2002,7 +2003,7 @@ export function TopToolbar({ // Shared projects are opened on another machine where the local files // don't exist, so always embed the vector data (never file references). const { project, defaultProjectName } = await projectFiles.buildEmbeddedProject(title); - const redacted = redactProjectCredentials(project); + const redacted = redactProjectCredentials(excludeHiddenFieldsFromProject(project)); // Strip path separators, control chars, and other characters that are // illegal in filenames so the server gets a predictable name. const safeName = defaultProjectName.replace( diff --git a/apps/geolibre-desktop/src/components/panels/AttributeTable.tsx b/apps/geolibre-desktop/src/components/panels/AttributeTable.tsx index 3c54aa442..b5d9bc452 100644 --- a/apps/geolibre-desktop/src/components/panels/AttributeTable.tsx +++ b/apps/geolibre-desktop/src/components/panels/AttributeTable.tsx @@ -5,6 +5,7 @@ import { isDuckDBQueryLayer, useAppStore, validateAttributeFormValues, + excludeHiddenFieldsFromGeojson, type AttributeFormConfig, type AttributeFormFieldConfig, type AttributeFormFieldError, @@ -70,6 +71,7 @@ import { Telescope, Trash2, X, + Ban, } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, @@ -927,8 +929,11 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) { try { setExportError(null); setExportWarning(null); - const exportGeojson = geojsonWithDrafts(); + let exportGeojson = geojsonWithDrafts(); if (!exportGeojson) return; + if (layer.fieldVisibility) { + exportGeojson = excludeHiddenFieldsFromGeojson(exportGeojson, layer.fieldVisibility); + } const baseName = sanitizeExportFileName(layer.name); const savedPath = await exportVectorLayer(exportGeojson, format, baseName, layer.name); @@ -995,6 +1000,19 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) { updateLayer(layer.id, toggleColumnHidden(layer, col)); }; + const handleToggleExcluded = (col: string) => { + if (!layer) return; + const current = layer.fieldVisibility || {}; + const isExcluded = current[col] === "excluded"; + const next = { ...current }; + if (isExcluded) { + delete next[col]; + } else { + next[col] = "excluded"; + } + updateLayer(layer.id, { fieldVisibility: next }); + }; + const handleShowAllColumns = () => { if (!layer) return; updateLayer(layer.id, showAllColumns(layer)); @@ -1368,6 +1386,12 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) { {t("attributeTable.hideField")} + handleToggleExcluded(col)}> + + {layer?.fieldVisibility?.[col] === "excluded" + ? t("attributeTable.includeField", "Include field on export") + : t("attributeTable.excludeField", "Exclude field on export")} + handleMoveColumn(col, isRtl ? "right" : "left")} diff --git a/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx b/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx index f782c739d..c96f802c3 100644 --- a/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx +++ b/apps/geolibre-desktop/src/components/panels/LayerPanel.tsx @@ -27,6 +27,7 @@ import { pluginOwnsPaint, supportsBridgedOpacity, useAppStore, + excludeHiddenFieldsFromGeojson, } from "@geolibre/core"; import type { EllipsoidId, GeoLibreLayer, LayerGroup } from "@geolibre/core"; import type { FeatureCollection } from "geojson"; @@ -1542,8 +1543,11 @@ export function LayerPanel({ scheduleStatusClear(layer.id); return; } + const egressGeojson = layer.fieldVisibility + ? excludeHiddenFieldsFromGeojson(geojson, layer.fieldVisibility) + : geojson; const savedPath = await exportVectorLayer( - geojson, + egressGeojson, format, sanitizeExportFileName(layer.name), layer.name, @@ -1915,6 +1919,11 @@ export function LayerPanel({ connection, schema_name: schema, table, + excluded_fields: layer.fieldVisibility + ? Object.keys(layer.fieldVisibility).filter( + (k) => layer.fieldVisibility![k] === "excluded", + ) + : undefined, }); } catch { // The write committed; only the refresh failed. Reporting this as diff --git a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts index 15227ad35..f878eaf4f 100644 --- a/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts +++ b/apps/geolibre-desktop/src/hooks/useProjectFileActions.ts @@ -3,6 +3,7 @@ import { detachProjectCopy, projectFromStore, redactProjectCredentials, + excludeHiddenFieldsFromProject, serializeProject, useAppStore, type GeoLibreLayer, @@ -843,13 +844,18 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) { // them. Make keeping them an explicit choice and use the same central // redaction pass as every external egress. let contentToSave = content; - const redacted = redactProjectCredentials(project); + const projectToEgress = excludeHiddenFieldsFromProject(project); + const redacted = redactProjectCredentials(projectToEgress); if (redacted.redactedPaths.length > 0) { const choice = await askStripCredentials(redacted.redactedCount); if (choice === "cancel") return false; if (choice === "strip") { contentToSave = serializeProject(redacted.project); + } else { + contentToSave = serializeProject(projectToEgress); } + } else { + contentToSave = serializeProject(projectToEgress); } // Projects opened from a URL have no writable path, so both Save and // Save As fall back to the save dialog for them. diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 693ddf518..14ccbf6a4 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -4207,6 +4207,8 @@ "manageFieldAria": "Manage field {{name}}", "renameField": "Rename field", "hideField": "Hide field", + "excludeField": "Exclude field on export", + "includeField": "Include field on export", "moveLeft": "Move left", "moveRight": "Move right", "deleteField": "Delete field", diff --git a/backend/geolibre_server/geolibre_server/app/postgis.py b/backend/geolibre_server/geolibre_server/app/postgis.py index 9dc9bf08e..28213f744 100644 --- a/backend/geolibre_server/geolibre_server/app/postgis.py +++ b/backend/geolibre_server/geolibre_server/app/postgis.py @@ -256,6 +256,7 @@ class PostgisReadRequest(BaseModel): connection: str schema_name: str = "public" table: str + excluded_fields: list[str] = [] class PostgisWriteRequest(BaseModel): @@ -538,8 +539,12 @@ def postgis_read(request: PostgisReadRequest) -> dict[str, Any]: if info["srid"] not in (0, 4326) else sql.SQL("ST_AsGeoJSON({geom})").format(geom=geom) ) + pk = info["primary_key"] + read_columns = [ + col for col in info["columns"] if col not in request.excluded_fields or col == pk + ] column_list = sql.SQL(", ").join( - [geom_expr] + [sql.Identifier(column) for column in info["columns"]] + [geom_expr] + [sql.Identifier(col) for col in read_columns] ) query = sql.SQL("SELECT {columns} FROM {schema}.{table} LIMIT %s").format( columns=column_list, @@ -576,14 +581,18 @@ def postgis_read(request: PostgisReadRequest) -> dict[str, Any]: pk = info["primary_key"] features = [] for row in rows: - properties = {column: _json_safe(value) for column, value in zip(info["columns"], row[1:])} + properties_raw = { + column: _json_safe(value) for column, value in zip(read_columns, row[1:], strict=True) + } feature: dict[str, Any] = { "type": "Feature", "geometry": json.loads(row[0]) if row[0] else None, - "properties": properties, + "properties": { + k: v for k, v in properties_raw.items() if k not in request.excluded_fields + }, } - if pk is not None and properties.get(pk) is not None: - feature["id"] = properties[pk] + if pk is not None and properties_raw.get(pk) is not None: + feature["id"] = properties_raw[pk] features.append(feature) return { diff --git a/backend/geolibre_server/tests/test_postgis.py b/backend/geolibre_server/tests/test_postgis.py index c041d91ab..0be010a6b 100644 --- a/backend/geolibre_server/tests/test_postgis.py +++ b/backend/geolibre_server/tests/test_postgis.py @@ -361,6 +361,24 @@ def test_read_returns_wgs84_with_primary_key(live_table) -> None: assert knox["id"] == knox["properties"]["gid"] +@requires_live_postgis +def test_read_drops_excluded_fields(live_table) -> None: + result = postgis_read( + PostgisReadRequest( + connection=LIVE_DSN, table=TABLE, excluded_fields=["population", "name", "gid"] + ) + ) + features = result["geojson"]["features"] + assert len(features) == 3 + knox = features[0] + assert "population" not in knox["properties"] + assert "name" not in knox["properties"] + assert "gid" not in knox["properties"] + # The geometry and id must still be populated correctly. + assert "geometry" in knox + assert "id" in knox + + @requires_live_postgis def test_read_unknown_table_404(live_table) -> None: with pytest.raises(HTTPException) as exc: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bb92ef2fa..315f778b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,3 +130,4 @@ export { redactUrlCredentials, type CredentialRedactionResult, } from "./credentials"; +export { excludeHiddenFieldsFromGeojson, excludeHiddenFieldsFromProject } from "./visibility"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0b0b62160..68bb0dd09 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -852,6 +852,13 @@ export interface LayerConnection { onFailure: "keep-last" | "clear"; } +/** + * Visibility of a layer's attribute field. + * - "hidden": Not shown in the attribute table, identify popup, tooltips, or field pickers, but remains in the data. + * - "excluded": Removed entirely from the data when the project is shared or exported. + */ +export type FieldVisibility = "hidden" | "excluded"; + export interface GeoLibreLayer { id: string; name: string; @@ -863,6 +870,11 @@ export interface GeoLibreLayer { metadata: Record; beforeId?: string; geojson?: FeatureCollection; + /** + * Field-level visibility overrides. Fields marked as "excluded" are physically + * removed from the data during export and sharing. + */ + fieldVisibility?: Record; /** * Per-field edit-widget, constraint, and visibility configuration authored * in the Attribute Form designer. Applied by the attribute editing surfaces diff --git a/packages/core/src/visibility.ts b/packages/core/src/visibility.ts new file mode 100644 index 000000000..b73cea70a --- /dev/null +++ b/packages/core/src/visibility.ts @@ -0,0 +1,76 @@ +import type { FeatureCollection } from "geojson"; +import type { GeoLibreProject, FieldVisibility } from "./types"; + +/** + * Returns a new FeatureCollection with properties marked as "excluded" removed. + */ +export function excludeHiddenFieldsFromGeojson( + geojson: FeatureCollection, + fieldVisibility?: Record, +): FeatureCollection { + const excludedKeys = new Set( + Object.entries(fieldVisibility || {}) + .filter(([_, visibility]) => visibility === "excluded") + .map(([key]) => key), + ); + + if (excludedKeys.size === 0) { + return geojson; + } + + // Deep clone to avoid mutating the live store state + const stripped: FeatureCollection = { + ...geojson, + features: geojson.features.map((feature) => { + const properties = { ...feature.properties }; + for (const key of excludedKeys) { + delete properties[key]; + } + return { ...feature, properties }; + }), + }; + + return stripped; +} + +/** + * Returns a new GeoLibreProject where all layers have their excluded fields + * physically removed from their inline GeoJSON. + */ +export function excludeHiddenFieldsFromProject(project: GeoLibreProject): GeoLibreProject { + let changed = false; + const layers = project.layers.map((layer) => { + if (!layer.fieldVisibility) return layer; + + let updatedLayer = layer; + + if (layer.geojson) { + const strippedGeojson = excludeHiddenFieldsFromGeojson(layer.geojson, layer.fieldVisibility); + if (strippedGeojson !== layer.geojson) { + changed = true; + updatedLayer = { ...updatedLayer, geojson: strippedGeojson }; + } + } + + if (layer.metadata?.embeddedGeoJSON) { + const strippedEmbedded = excludeHiddenFieldsFromGeojson( + layer.metadata.embeddedGeoJSON as FeatureCollection, + layer.fieldVisibility, + ); + if (strippedEmbedded !== layer.metadata.embeddedGeoJSON) { + changed = true; + updatedLayer = { + ...updatedLayer, + metadata: { + ...updatedLayer.metadata, + embeddedGeoJSON: strippedEmbedded, + }, + }; + } + } + + return updatedLayer; + }); + + return changed ? { ...project, layers } : project; +} diff --git a/packages/processing/src/sidecar-client.ts b/packages/processing/src/sidecar-client.ts index 33c0c5533..0675e97a8 100644 --- a/packages/processing/src/sidecar-client.ts +++ b/packages/processing/src/sidecar-client.ts @@ -796,6 +796,7 @@ export interface ReadPostgisTableRequest { connection: string; schema_name?: string; table: string; + excluded_fields?: string[]; } export interface ReadPostgisTableResult { diff --git a/tests/visibility.test.ts b/tests/visibility.test.ts new file mode 100644 index 000000000..885438607 --- /dev/null +++ b/tests/visibility.test.ts @@ -0,0 +1,64 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import type { GeoLibreProject } from "@geolibre/core"; +import { excludeHiddenFieldsFromProject } from "../packages/core/src/visibility"; + +describe("visibility", () => { + test("excludeHiddenFieldsFromProject strips excluded fields from geojson and embeddedGeoJSON", () => { + const project: GeoLibreProject = { + id: "proj-1", + name: "Test", + version: 1, + viewState: { + longitude: 0, + latitude: 0, + zoom: 0, + pitch: 0, + bearing: 0, + }, + layers: [ + { + id: "layer-1", + name: "Layer", + type: "geojson", + visible: true, + metadata: { + embeddedGeoJSON: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [0, 0] }, + properties: { keep: 1, drop: 2 }, + }, + ], + }, + }, + fieldVisibility: { drop: "excluded" }, + geojson: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [0, 0] }, + properties: { keep: 1, drop: 2 }, + }, + ], + }, + }, + ], + }; + + const stripped = excludeHiddenFieldsFromProject(project); + + // Check main geojson + const feature1 = stripped.layers[0].geojson!.features[0]; + assert.strictEqual(feature1.properties?.keep, 1); + assert.strictEqual(feature1.properties?.drop, undefined); + + // Check embedded geojson + const embeddedFeature = (stripped.layers[0].metadata.embeddedGeoJSON as any).features[0]; + assert.strictEqual(embeddedFeature.properties?.keep, 1); + assert.strictEqual(embeddedFeature.properties?.drop, undefined); + }); +});