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);
+ });
+});