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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
DEFAULT_PROJECT_NAME,
excludeHiddenFieldsFromProject,
redactProjectCredentials,
serializeProject,
useAppStore,
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 25 additions & 1 deletion apps/geolibre-desktop/src/components/panels/AttributeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isDuckDBQueryLayer,
useAppStore,
validateAttributeFormValues,
excludeHiddenFieldsFromGeojson,
type AttributeFormConfig,
type AttributeFormFieldConfig,
type AttributeFormFieldError,
Expand Down Expand Up @@ -70,6 +71,7 @@ import {
Telescope,
Trash2,
X,
Ban,
} from "lucide-react";
import {
type MouseEvent as ReactMouseEvent,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -1368,6 +1386,12 @@ export function AttributeTable({ mapControllerRef }: AttributeTableProps) {
<EyeOff className="me-2 h-3.5 w-3.5" />
{t("attributeTable.hideField")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleToggleExcluded(col)}>
<Ban className="me-2 h-3.5 w-3.5" />
{layer?.fieldVisibility?.[col] === "excluded"
? t("attributeTable.includeField", "Include field on export")
: t("attributeTable.excludeField", "Exclude field on export")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={isRtl ? index === columns.length - 1 : index === 0}
onSelect={() => handleMoveColumn(col, isRtl ? "right" : "left")}
Expand Down
11 changes: 10 additions & 1 deletion apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
detachProjectCopy,
projectFromStore,
redactProjectCredentials,
excludeHiddenFieldsFromProject,
serializeProject,
useAppStore,
type GeoLibreLayer,
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: contentToSave is now unconditionally reassigned in every branch of the if/else below (both the redactedPaths.length > 0 "keep" branch and the else branch call serializeProject(projectToEgress), and "strip" calls serializeProject(redacted.project)), so this initial let contentToSave = content; is dead — it's never read before being overwritten. Slightly misleading since it looks like a real fallback. Could simplify to declare contentToSave inside the branches, or drop the outer content variable if it's now unused elsewhere.

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.
Expand Down
2 changes: 2 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions backend/geolibre_server/geolibre_server/app/postgis.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ class PostgisReadRequest(BaseModel):
connection: str
schema_name: str = "public"
table: str
excluded_fields: list[str] = []


class PostgisWriteRequest(BaseModel):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions backend/geolibre_server/tests/test_postgis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,4 @@ export {
redactUrlCredentials,
type CredentialRedactionResult,
} from "./credentials";
export { excludeHiddenFieldsFromGeojson, excludeHiddenFieldsFromProject } from "./visibility";
12 changes: 12 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -863,6 +870,11 @@ export interface GeoLibreLayer {
metadata: Record<string, unknown>;
beforeId?: string;
geojson?: FeatureCollection;
/**
* Field-level visibility overrides. Fields marked as "excluded" are physically
* removed from the data during export and sharing.
*/
fieldVisibility?: Record<string, FieldVisibility>;
/**
* Per-field edit-widget, constraint, and visibility configuration authored
* in the Attribute Form designer. Applied by the attribute editing surfaces
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/visibility.ts
Original file line number Diff line number Diff line change
@@ -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<string, FieldVisibility>,
): 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;
}
1 change: 1 addition & 0 deletions packages/processing/src/sidecar-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ export interface ReadPostgisTableRequest {
connection: string;
schema_name?: string;
table: string;
excluded_fields?: string[];
}

export interface ReadPostgisTableResult {
Expand Down
64 changes: 64 additions & 0 deletions tests/visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading