Skip to content

Commit 28a5c14

Browse files
committed
feat: read zoom from optional ?z= on clear-coordinate links
onGe0Decode now sources zoom as: ?z= query param -> legacy /lat,lon,zoom path segment -> default 14. ?z= is optional, so existing links (path-zoom or none) keep working. Widens normalizeZoom's cap 19->20 to match the app's kMaxZoom (organicmaps libs/ge0/geo_url_parser.cpp) so a valid share never silently falls back to 14. Name parsing, XSS escaping, and the ge0 fallback are unchanged; path+query (incl. ?z=) still flows into the om:/ deep link, so the app receives the zoom too. Adds a cross-reference to the app's synced clear-coordinate parser. Extends the jest suite: ?z= precedence/optional/name-coexistence/out-of-range, and normalizeZoom 1..20.
1 parent 8e9224a commit 28a5c14

2 files changed

Lines changed: 47 additions & 6 deletions

File tree

src/ge0.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ export function normalizeNameAndTitle(name: string | undefined): [string, string
4343
return [name, title];
4444
}
4545

46-
export function normalizeZoom(zoom: string): number {
46+
export function normalizeZoom(zoom: string | undefined): number {
4747
const DEFAULT_ZOOM = 14;
48-
const z = parseInt(zoom);
49-
if (isNaN(z)) return DEFAULT_ZOOM;
50-
if (z < 1 || z > 19) return DEFAULT_ZOOM;
48+
// Upper bound mirrors the app's kMaxZoom (organicmaps libs/ge0/geo_url_parser.cpp, GeoURLInfo::SetZoom).
49+
const MAX_ZOOM = 20;
50+
if (!zoom || !/^\d+$/.test(zoom)) return DEFAULT_ZOOM;
51+
const z = Number(zoom);
52+
if (z < 1 || z > MAX_ZOOM) return DEFAULT_ZOOM;
5153
return z;
5254
}
5355

@@ -104,6 +106,9 @@ function renderTemplate(template: string, llz: LatLonZoom, name: string, title:
104106
return new Response(template, { headers: { 'content-type': 'text/html' } });
105107
}
106108

109+
// Clear decimal coordinates: /lat,lon[,zoom][/name]. An optional ?z=<zoom> query param takes
110+
// precedence over the path zoom (see onGe0Decode). Kept in sync with the app's clear-coordinate
111+
// parser (organicmaps libs/ge0/geo_url_parser.cpp) — same grammar, same ?z= precedence and zoom cap.
107112
// Coordinates and zoom are validated separately.
108113
export const CLEAR_COORDINATES_REGEX =
109114
/(?<lat>-?\d+\.\d+)[^.](?<lon>-?\d+\.\d+)(?:[^.](?<zoom>\d{1,2}))?(?:[^\d.](?<name>.+))?/;
@@ -115,7 +120,9 @@ export async function onGe0Decode(template: string, url: string): Promise<Respon
115120

116121
const m = pathname.match(CLEAR_COORDINATES_REGEX);
117122
if (m && m.groups) {
118-
const llz = { lat: Number(m.groups.lat), lon: Number(m.groups.lon), zoom: normalizeZoom(m.groups.zoom) };
123+
// Zoom precedence: ?z= query param, then a legacy /lat,lon,zoom path segment, then the default.
124+
const zoom = normalizeZoom(new URLSearchParams(search).get('z') ?? m.groups.zoom);
125+
const llz = { lat: Number(m.groups.lat), lon: Number(m.groups.lon), zoom };
119126
if (llz.lat <= -90.0 || llz.lat >= 90.0 || llz.lon <= -180.0 || llz.lon >= 180.0)
120127
throw new Error(`Invalid coordinates ${m.groups.lat} and ${m.groups.lon}`);
121128

test/api.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,21 @@ describe('decodeLatLonZoom — ge0 binary short links', () => {
2222
});
2323

2424
describe('normalizeZoom', () => {
25-
test('keeps zoom levels within 1..19', () => {
25+
test('keeps zoom levels within 1..20 (matches the app kMaxZoom)', () => {
2626
expect(normalizeZoom('1')).toBe(1);
2727
expect(normalizeZoom('15')).toBe(15);
2828
expect(normalizeZoom('19')).toBe(19);
29+
expect(normalizeZoom('20')).toBe(20);
2930
});
3031

3132
test('falls back to the default zoom (14) for missing or out-of-range values', () => {
3233
expect(normalizeZoom('')).toBe(14);
3334
expect(normalizeZoom('0')).toBe(14);
35+
expect(normalizeZoom('21')).toBe(14);
3436
expect(normalizeZoom('99')).toBe(14);
3537
expect(normalizeZoom('abc')).toBe(14);
38+
expect(normalizeZoom('16abc')).toBe(14);
39+
expect(normalizeZoom('1.5')).toBe(14);
3640
});
3741
});
3842

@@ -129,4 +133,34 @@ describe('onGe0Decode — end to end', () => {
129133
test('rejects out-of-range clear-text coordinates', async () => {
130134
await expect(onGe0Decode(TEMPLATE, 'https://omaps.app/91.0,0.0')).rejects.toThrow(/Invalid coordinates/);
131135
});
136+
137+
test('reads zoom from the ?z= query param', async () => {
138+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56?z=16')).text();
139+
expect(html).toContain('<coords>53.9,27.56@16</coords>');
140+
});
141+
142+
test('?z= takes precedence over a legacy path zoom', async () => {
143+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56,15?z=16')).text();
144+
expect(html).toContain('<coords>53.9,27.56@16</coords>');
145+
});
146+
147+
test('?z= is optional — falls back to the path zoom, then the default', async () => {
148+
const path = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56,15')).text();
149+
expect(path).toContain('<coords>53.9,27.56@15</coords>');
150+
const bare = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56')).text();
151+
expect(bare).toContain('<coords>53.9,27.56@14</coords>');
152+
});
153+
154+
test('?z= coexists with a pin name: /lat,lon/Name?z=', async () => {
155+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56/Minsk?z=12')).text();
156+
expect(html).toContain('<coords>53.9,27.56@12</coords>');
157+
expect(html).toContain('<name>Minsk</name>');
158+
});
159+
160+
test('out-of-range ?z= falls back to the default zoom', async () => {
161+
for (const z of ['0', '99', 'abc', '16abc', '1.5']) {
162+
const html = await (await onGe0Decode(TEMPLATE, `https://omaps.app/53.9,27.56?z=${z}`)).text();
163+
expect(html).toContain('<coords>53.9,27.56@14</coords>');
164+
}
165+
});
132166
});

0 commit comments

Comments
 (0)