Merge upstream/main: Street View rework meets i18n

Upstream's Street View rework (second imagery provider, one source at a
time, OPEN STREET VIEW button, key-alternative guidance) rewrote exactly
the street-level regions this branch localizes.

Resolved as upstream logic + re-applied localization:

- keySetupCore: new unlock copy kept as msg() data; added
  keySetupAlternativeKeyNames() returning machine values (registry key
  names) so key-setup core stays English while presentation localizes.
- New src/ui/keyGuidance.js: localized key-guidance composer shared by
  layerPanel and streetLevelPresentation (requirement sentence plus the
  "or light X to use your Y key" alternatives tail, placeholders only).
- streetLevelPresentation: keyGuidance()/presentNearest()/presentMeta
  relocalized (t/tn + formatNumber instead of toLocaleString);
  renderModes and month-precision dates kept from upstream.
- layer-panels.html: upstream row ids, sl-meta-row and the new button
  kept, data-i18n hooks re-applied (incl. OPEN STREET VIEW fallback).
- es/ru catalogs: 13 new keys, stale unlock key replaced in place.

npm run i18n: complete for es and ru. npm test: 6345/6347; the one
failure (devFreshDotenv provenance) also fails on plain upstream/main
and on the pre-merge tip here - this machine leaks OPENAI_API_KEY into
spawned children with a replaced env, so it is environmental.
This commit is contained in:
Meir Michanie committed 2026-10-13 18:33:44 +02:00
commit eb802408e4
59 files changed
+4009 -199

No files matched your search

+81 -22
View File
@@ -1,4 +1,4 @@
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { keyRequirementText, withKeyAlternatives } from './keyGuidance.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -51,21 +51,29 @@ function presentSince(days, now) {
};
}
function formatDate(ms) {
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
function formatDate(ms, precision = 'day') {
if (!Number.isFinite(ms)) return '';
try {
return new Date(ms).toISOString().slice(0, 10);
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
} catch {
return '';
}
}
/** The Provider Settings hint for a missing key, or '' for a hidden key. */
function keyRequirement(id) {
const envVars = keySetupRequirementEnvVars(id);
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
/**
* What the key-gated layer needs: each switched-on provider's key, and the
* switched-off ones that would draw with a key already set.
*/
function keyGuidance(state) {
const needs = (state.providers || [])
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keyRequirementText(p.requiresKeyId))
.filter(Boolean);
return withKeyAlternatives(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
}
function presentStatus(state) {
@@ -76,7 +84,12 @@ function presentStatus(state) {
if (state.keyRejected)
return { text: t('KEY REJECTED'), tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: t('KEY REQUIRED'), tone: 'warn', pressed, title };
return {
text: t('KEY REQUIRED'),
tone: 'warn',
pressed,
title: keyGuidance(state) || title,
};
if (state.coverage.loading)
return { text: t('LOADING'), tone: 'busy', pressed, title };
return pressed
@@ -96,7 +109,7 @@ function presentProviders(state) {
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${provider.error}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keyRequirement(provider.requiresKeyId)}`;
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
return {
id: provider.id,
@@ -123,14 +136,17 @@ function presentViewer(state) {
if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing)}°`);
if (street.capturedAt) right.push(formatDate(street.capturedAt));
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(t('Image by {creator}', { creator: street.creator }));
return {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: street.creator
? t('Image by {creator}', { creator: street.creator })
: '',
renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
@@ -149,10 +165,42 @@ function presentViewer(state) {
};
}
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
function presentNearest(state) {
const targets = (state.providers || []).filter(
(provider) =>
provider.on && provider.groundClick && provider.keyRequired !== true,
);
if (state.enabled !== true || !targets.length)
return {
visible: false,
label: '',
title: '',
disabled: true,
providerIds: [],
};
const names = targets.map((provider) => provider.name).join(t(' or '));
const ready = state.groundClickReady === true;
return {
visible: true,
label: t('OPEN {labels}', {
labels: targets.map((provider) => provider.label).join(' / '),
}),
title: ready
? t('Open {names} at the centre of the view', { names })
: t('Zoom in to a street to open {names} here', { names }),
disabled: !ready || state.street.loading === true,
providerIds: targets.map((provider) => provider.id),
};
}
function presentMeta(state) {
if (state.providers.length === 0)
return state.coverage.hint ? t(state.coverage.hint) : '';
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
if (state.sequence.selectedId)
return state.sequence.loading
? t('Loading this sequence…')
@@ -162,14 +210,20 @@ function presentMeta(state) {
'{n} images in this sequence · Esc clears',
{ n: formatNumber(state.sequence.images) },
);
const hint = state.coverage.hint ? t(state.coverage.hint) : '';
if (state.coverage.count > 0)
return tn(
state.coverage.count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: formatNumber(state.coverage.count) },
);
return state.coverage.hint ? t(state.coverage.hint) : '';
return [
tn(
state.coverage.count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: formatNumber(state.coverage.count) },
),
hint,
]
.filter(Boolean)
.join(' · ');
return hint;
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
@@ -190,10 +244,15 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true,
};
}