mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
* feat(bookmarks): save bookmarks in the project Bookmarks lived only in the browser's localStorage, so they did not travel with a .geolibre.json and the Python package and MCP server could not author them. - Project format: optional `bookmarks` and `bookmarkGroups` (folders), normalized on load (camera range checks, duplicate ids, bearing wrap, dangling folder ids dropped) and omitted when empty. - Store: `bookmarks`, `bookmarkGroups` and `setBookmarks`; saved, autosaved and shared in collaboration like other project sections. - Bookmarks panel: no longer persists to localStorage. It opens with the project's bookmarks, writes every edit (including host-dialog imports) back to the project, and rebuilds when a project load or a collaborator replaces them. Bookmarks saved by older versions move into the first project opened that has none, once. - Python `Map.add_bookmark` / `remove_bookmark` and MCP tools `add_bookmark` / `remove_bookmark` (camera from the saved view by default, folder by id or name, layers to show), plus bookmarks in `describe_project`. The Python round-trip fixture now carries bookmarks through the app's parseProject. - Docs: project format, Python, MCP, user guide, and the agent skill. Part of #2869 * Address review feedback - Trim a bookmark's groupId before matching it to the (trimmed) folder ids, so a hand-edited id with spaces is not silently un-filed (Claude review). - Legacy migration no longer deletes the old localStorage bookmarks: a marker key records the one-time copy, so bookmarks copied into the wrong project stay recoverable from browser storage (Claude review). - Python add_bookmark refuses a 501st bookmark before changing the project, matching the app's MAX_PROJECT_BOOKMARKS (500), with a drift test that pins the two together (CodeRabbit). * Address review feedback - Write the legacy-bookmark migration marker only once the project that received them is saved; a project discarded or replaced unsaved leaves it unset so the next empty project gets the copy (CodeRabbit). - Correct the user guide: the old browser copy is left in place but no longer updated, not kept as a backup (Claude). - Move the bookmarkGroups normalization above the print-layout comment so the comment stays attached to its code (Claude).
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { describe, it } from "node:test";
|
|
|
|
import {
|
|
DEFAULT_LAYER_STYLE,
|
|
normalizeLayerDescriptiveMetadata,
|
|
parseProject,
|
|
validateLayerDescriptiveMetadata,
|
|
validateMapExpression,
|
|
} from "@geolibre/core";
|
|
|
|
// Written by the geolibre Python package's authoring functions
|
|
// (python/tests/test_authoring_parity.py pins it to what they produce). Loading
|
|
// it through the app's own parseProject proves the layer filter, labels, plugin
|
|
// state and story map the Python package and MCP server write are read back
|
|
// unchanged, rather than silently normalized away.
|
|
const fixtureText = readFileSync(
|
|
new URL("./fixtures/python-authoring.geolibre.json", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const fixture = JSON.parse(fixtureText);
|
|
|
|
/** Drop `undefined` members the normalizers add, so shapes compare as JSON. */
|
|
function asJson(value: unknown): unknown {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
describe("Python-authored project round trip", () => {
|
|
const project = parseProject(fixtureText);
|
|
const layer = project.layers.find((item) => item.id === "cities");
|
|
const source = fixture.layers.find((item: { id: string }) => item.id === "cities");
|
|
|
|
it("keeps the persistent layer filter", () => {
|
|
assert.ok(layer);
|
|
assert.deepEqual(layer.filterExpression, source.filterExpression);
|
|
assert.ok(
|
|
validateMapExpression(JSON.stringify(source.filterExpression), { expectedType: "boolean" })
|
|
.ok,
|
|
);
|
|
});
|
|
|
|
it("keeps the descriptive metadata, already in its normalized form", () => {
|
|
assert.ok(layer);
|
|
assert.ok(source.descriptiveMetadata);
|
|
assert.deepEqual(layer.descriptiveMetadata, source.descriptiveMetadata);
|
|
assert.deepEqual(
|
|
normalizeLayerDescriptiveMetadata(source.descriptiveMetadata),
|
|
source.descriptiveMetadata,
|
|
);
|
|
assert.deepEqual(validateLayerDescriptiveMetadata(layer.descriptiveMetadata), []);
|
|
});
|
|
|
|
it("keeps every label setting, with no key the app does not know", () => {
|
|
assert.ok(layer);
|
|
assert.deepEqual(layer.style.labels, source.style.labels);
|
|
assert.deepEqual(
|
|
Object.keys(source.style.labels).sort(),
|
|
Object.keys(DEFAULT_LAYER_STYLE.labels).sort(),
|
|
);
|
|
for (const key of ["sizeExpression", "visibilityExpression"] as const) {
|
|
assert.ok(validateMapExpression(layer.style.labels[key]).ok, key);
|
|
}
|
|
assert.ok(
|
|
validateMapExpression(layer.style.labels.visibilityExpression, { expectedType: "boolean" })
|
|
.ok,
|
|
);
|
|
});
|
|
|
|
it("keeps the plugin state, its corner, and the default plugins", () => {
|
|
assert.ok(project.plugins);
|
|
assert.deepEqual(project.plugins.settings, fixture.plugins.settings);
|
|
assert.deepEqual(project.plugins.mapControlPositions, fixture.plugins.mapControlPositions);
|
|
assert.deepEqual(project.plugins.activePluginIds, fixture.plugins.activePluginIds);
|
|
});
|
|
|
|
it("keeps the story map and its chapters exactly", () => {
|
|
assert.deepEqual(asJson(project.storymap), fixture.storymap);
|
|
assert.deepEqual(
|
|
project.storymap?.chapters.map((chapter) => chapter.id),
|
|
["overview", "close-up"],
|
|
);
|
|
});
|
|
|
|
it("keeps the bookmarks and their folder, already in normalized form", () => {
|
|
assert.ok(Array.isArray(fixture.bookmarks) && fixture.bookmarks.length === 2);
|
|
assert.deepEqual(asJson(project.bookmarks), fixture.bookmarks);
|
|
assert.deepEqual(asJson(project.bookmarkGroups), fixture.bookmarkGroups);
|
|
assert.equal(project.bookmarks?.[0].groupId, "hoods");
|
|
assert.deepEqual(project.bookmarks?.[0].extra, { visibleLayerIds: ["cities"] });
|
|
});
|
|
});
|