Preserve custom Street Level provider switches in shared state

This commit is contained in:
Sameh Khamis committed 2026-10-09 17:53:47 -07:00
1 parent 3b840ebdf3
commit 7b1b2df764
11 files changed
+238 -9

No files matched your search

+10 -1
View File
@@ -1,3 +1,4 @@
import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -411,8 +412,16 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
Object.freeze({
key: 'providerSwitches',
token: 'r',
defaultValue: '',
normalize: normalizeProviderSwitches,
encode: normalizeProviderSwitches,
decode: normalizeProviderSwitches,
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
+3
View File
@@ -2660,12 +2660,14 @@ test('Street Level: every option round-trips through a share link and stored sta
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
@@ -2693,6 +2695,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
+11
View File
@@ -98,6 +98,17 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(
cwd,
'src/layers/streetLevel/providerSwitches.js',
);
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(
new URL('../layers/streetLevel/providerSwitches.js', import.meta.url),
),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
+16 -5
View File
@@ -1,13 +1,20 @@
import { normalizeFilter } from './filter.js';
import {
encodeProviderSwitches,
decodeProviderSwitches,
} from './providerSwitches.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeProviderSwitches(entries);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
@@ -23,8 +30,12 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const switches = decodeProviderSwitches(source.providerSwitches);
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
providers.set(id, source[id]);
}
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
+7 -1
View File
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax=0',
pano: 'flat',
sinceDays: 365,
},
);
});
@@ -0,0 +1,45 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)=([01])$/;
/** Canonical, bounded provider switches; Mapillary retains its legacy token. */
export function normalizeProviderSwitches(value) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split(',')) {
const match = ENTRY.exec(field);
if (!match || match[1] === 'mapillary' || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}=${on ? '1' : '0'}`)
.join(',');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeProviderSwitches(providers) {
const value = [...providers]
.filter(([id]) => id !== 'mapillary')
.map(([id, on]) => `${id}=${on === true ? '1' : '0'}`)
.join(',');
const normalized = normalizeProviderSwitches(value);
if (normalized === null)
throw new TypeError('Invalid or oversized Street Level provider switches');
return normalized;
}
/** A Map keeps provider IDs separate from object prototype properties. */
export function decodeProviderSwitches(value) {
const normalized = normalizeProviderSwitches(value);
return new Map(
normalized
? normalized.split(',').map((entry) => {
const [id, on] = entry.split('=');
return [id, on === '1'];
})
: [],
);
}
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeProviderSwitches,
encodeProviderSwitches,
} from './providerSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { validateProviders } from './registry.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
const filter = { pano: 'flat', sinceDays: 365 };
test('custom switches survive sharing and storage without depending on provider order', () => {
const providers = [
['panoramax', false],
['example', true],
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example=1,panoramax=0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
options.providerSwitches,
);
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = options;
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example=1,panoramax=0/);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
]) {
const decoded = decodeParams(restored.options['street-level'], {
providerIds: ['example', 'mapillary', 'panoramax'],
filter: { pano: 'all', sinceDays: 0 },
});
assert.deepEqual(
[...decoded.providers],
[
['example', true],
['mapillary', false],
['panoramax', false],
],
);
assert.deepEqual(decoded.filter, filter);
assert.deepEqual(
[
...decodeParams(restored.options['street-level'], {
providerIds: ['panoramax'],
filter,
}).providers,
],
[['panoramax', false]],
);
assert.equal(
decodeParams(restored.options['street-level'], {
providerIds: [],
filter,
}).providers.size,
0,
);
}
});
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
for (const [lo, on] of [
['', true],
['0.m.0', false],
]) {
const state = decodeLayerStateParams(
new URLSearchParams({ v: '2', l: '0', lo }),
);
const restored = decodeParams(state.options['street-level'], {
providerIds: ['mapillary', 'example'],
filter,
});
assert.deepEqual([...restored.providers], [['mapillary', on]]);
}
});
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example=0,example=1',
'mapillary=0',
'__proto__=1',
'a=2',
'a=1,',
'a.b=1',
'a_b=0',
`${'a'.repeat(255)}=1`,
null,
{},
]) {
assert.equal(normalizeProviderSwitches(value), null);
}
assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0');
assert.throws(
() => encodeProviderSwitches([['a'.repeat(255), true]]),
/oversized/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/oversized/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary=0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
+4
View File
@@ -1,3 +1,5 @@
import { encodeProviderSwitches } from './providerSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -125,6 +127,8 @@ export function validateProviders(providers) {
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
encodeProviderSwitches(providers.map(({ id }) => [id, true]));
return Object.freeze([...providers]);
}