Add first i18n infrastructure and translate utils and about-dialog

pull/256/head
Candid Dauth 2024-03-31 15:51:11 +02:00
rodzic 0ca3fa9356
commit f4c6a2f0cd
29 zmienionych plików z 680 dodań i 62 usunięć

Wyświetl plik

@ -27,7 +27,6 @@ module.exports = {
"import/no-extraneous-dependencies": ["error"],
"@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }],
"import/no-named-as-default": ["warn"],
"import/no-named-as-default-member": ["warn"],
"import/no-duplicates": ["warn"],
"import/namespace": ["error"],
"import/default": ["error"],

Wyświetl plik

@ -52,6 +52,7 @@
"facilmap-utils": "workspace:^",
"file-saver": "^2.0.5",
"hammerjs": "^2.0.8",
"i18next": "^23.10.1",
"jquery": "^3.7.1",
"leaflet": "^1.9.4",
"leaflet-draggable-lines": "^2.0.0",

Wyświetl plik

@ -0,0 +1,32 @@
const messagesDe = {
"about-dialog": {
"header": `Über FacilMap {{version}}`,
"license-text": `{{facilmap}} is unter der {{license}} verfügbar.`,
"license-text-facilmap": `FacilMap`,
"license-text-license": `GNU Affero General Public License, Version 3`,
"issues-text": `Bitte melden Sie Fehler und Verbesserungsvorschläge auf {{tracker}}.`,
"issues-text-tracker": `GitHub`,
"help-text": `Wenn Sie Fragen haben, schauen Sie sich die {{documentation}} an, schreiben Sie ins {{discussions}} oder fragen im {{chat}}.`,
"help-text-documentation": `Dokumentation`,
"help-text-discussions": `Forum`,
"help-text-chat": `Matrix-Chat`,
"privacy-information": `Informationen zum Datenschutz`,
"map-data": `Kartendaten`,
"map-data-search": `Suche`,
"map-data-pois": `POIs`,
"map-data-directions": `Routenberechnung`,
"map-data-geoip": `GeoIP`,
"map-data-geoip-description": `Dieses Produkt enthält GeoLine2-Daten von Maxmind, verfügbar unter {{maxmind}}.`,
"attribution-osm-contributors": `OSM-Mitwirkende`,
"programs-libraries": `Programme/Bibliotheken`,
"icons": `Symbole`
},
"modal-dialog": {
"close": "Schließen",
"cancel": "Abbrechen",
"save": "Speichern"
}
};
export default messagesDe;

Wyświetl plik

@ -0,0 +1,32 @@
const messagesEn = {
"about-dialog": {
"header": `About FacilMap {{version}}`,
"license-text": `{{facilmap}} is available under the {{license}}.`,
"license-text-facilmap": `FacilMap`,
"license-text-license": `GNU Affero General Public License, Version 3`,
"issues-text": `If something does not work or you have a suggestion for improvement, please report on the {{tracker}}.`,
"issues-text-tracker": `issue tracker`,
"help-text": `If you have a question, please have a look at the {{documentation}}, raise a question in the {{discussions}} or ask in the {{chat}}.`,
"help-text-documentation": `documentation`,
"help-text-discussions": `discussion forum`,
"help-text-chat": `Matrix chat`,
"privacy-information": `Privacy information`,
"map-data": `Map data`,
"map-data-search": `Search`,
"map-data-pois": `POIs`,
"map-data-directions": `Directions`,
"map-data-geoip": `GeoIP`,
"map-data-geoip-description": `This product includes GeoLite2 data created by MaxMind, available from {{maxmind}}.`,
"attribution-osm-contributors": `OSM Contributors`,
"programs-libraries": `Programs/libraries`,
"icons": `Icons`
},
"modal-dialog": {
"close": "Close",
"cancel": "Cancel",
"save": "Save"
}
};
export default messagesEn;

Wyświetl plik

@ -4,6 +4,9 @@
import { computed } from "vue";
import ModalDialog from "./ui/modal-dialog.vue";
import { injectContextRequired, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import { T, useI18n } from "../utils/i18n";
const { t } = useI18n();
const context = injectContextRequired();
const mapContext = requireMapContext(context);
@ -18,21 +21,49 @@
});
const fmVersion = __FM_VERSION__;
</script>
<template>
<ModalDialog
:title="`About FacilMap ${fmVersion}`"
:title="t('about-dialog.header', { version: fmVersion })"
class="fm-about"
size="lg"
@hidden="emit('hidden')"
>
<p><a href="https://github.com/facilmap/facilmap" target="_blank"><strong>FacilMap</strong></a> is available under the <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">GNU Affero General Public License, Version 3</a>.</p>
<p>If something does not work or you have a suggestion for improvement, please report on the <a href="https://github.com/FacilMap/facilmap/issues" target="_blank">issue tracker</a>.</p>
<p>If you have a question, please have a look at the <a href="https://docs.facilmap.org/users/" target="_blank">documentation</a>, raise a question in the <a href="https://github.com/FacilMap/facilmap/discussions" target="_blank">discussion forum</a> or ask in the <a href="https://matrix.to/#/#facilmap:rankenste.in" target="_blank">Matrix chat</a>.</p>
<p><a href="https://docs.facilmap.org/users/privacy/" target="_blank">Privacy information</a></p>
<h4>Map data</h4>
<p>
<T k="about-dialog.license-text">
<template #facilmap>
<a href="https://github.com/facilmap/facilmap" target="_blank"><strong>{{t('about-dialog.license-text-facilmap')}}</strong></a>
</template>
<template #license>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">{{t('about-dialog.license-text-license')}}</a>
</template>
</T>
</p>
<p>
<T k="about-dialog.issues-text">
<template #tracker>
<a href="https://github.com/FacilMap/facilmap/issues" target="_blank">{{t('about-dialog.issues-text-tracker')}}</a>
</template>
</T>
</p>
<p>
<T k="about-dialog.help-text">
<template #documentation>
<a href="https://docs.facilmap.org/users/" target="_blank">{{t('about-dialog.help-text-documentation')}}</a>
</template>
<template #discussions>
<a href="https://github.com/FacilMap/facilmap/discussions" target="_blank">{{t('about-dialog.help-text-discussions')}}</a>
</template>
<template #chat>
<a href="https://matrix.to/#/#facilmap:rankenste.in" target="_blank">{{t('about-dialog.help-text-chat')}}</a>
</template>
</T>
</p>
<p><a href="https://docs.facilmap.org/users/privacy/" target="_blank">{{t('about-dialog.privacy-information')}}</a></p>
<h4>{{t('about-dialog.map-data')}}</h4>
<dl class="row">
<template v-for="layer in layers">
<template v-if="layer.options.attribution">
@ -41,19 +72,25 @@
</template>
</template>
<dt class="col-sm-3">Search</dt>
<dd class="col-sm-9"><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">{{t('about-dialog.map-data-search')}}</dt>
<dd class="col-sm-9"><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">{{t('about-dialog.attribution-osm-contributors')}}</a></dd>
<dt class="col-sm-3">POIs</dt>
<dd class="col-sm-9"><a href="https://overpass-api.de/" target="_blank">Overpass API</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">{{t('about-dialog.map-data-pois')}}</dt>
<dd class="col-sm-9"><a href="https://overpass-api.de/" target="_blank">Overpass API</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">{{t('about-dialog.attribution-osm-contributors')}}</a></dd>
<dt class="col-sm-3">Directions</dt>
<dd class="col-sm-9"><a href="https://www.mapbox.com/api-documentation/#directions">Mapbox Directions API</a> / <a href="https://openrouteservice.org/">OpenRouteService</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">{{t('about-dialog.map-data-directions')}}</dt>
<dd class="col-sm-9"><a href="https://www.mapbox.com/api-documentation/#directions">Mapbox Directions API</a> / <a href="https://openrouteservice.org/">OpenRouteService</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">{{t('about-dialog.attribution-osm-contributors')}}</a></dd>
<dt class="col-sm-3">GeoIP</dt>
<dd class="col-sm-9">This product includes GeoLite2 data created by MaxMind, available from <a href="https://www.maxmind.com">https://www.maxmind.com</a>.</dd>
<dt class="col-sm-3">{{t('about-dialog.map-data-geoip')}}</dt>
<dd class="col-sm-9">
<T k="about-dialog.map-data-geoip-description">
<template #maxmind>
<a href="https://www.maxmind.com">https://www.maxmind.com</a>
</template>
</T>
</dd>
</dl>
<h4>Programs/libraries</h4>
<h4>{{t('about-dialog.programs-libraries')}}</h4>
<ul>
<li><a href="https://nodejs.org/" target="_blank">Node.js</a></li>
<li><a href="https://sequelize.org/" target="_blank">Sequelize</a></li>
@ -72,7 +109,7 @@
<li><a href="https://expressjs.com/" target="_blank">Express</a></li>
<li><a href="https://vuepress.vuejs.org/" target="_blank">Vuepress</a></li>
</ul>
<h4>Icons</h4>
<h4>{{t('about-dialog.icons')}}</h4>
<ul>
<li><a href="https://github.com/twain47/Open-SVG-Map-Icons/" target="_blank">Open SVG Map Icons</a></li>
<li><a href="https://glyphicons.com/" target="_blank">Glyphicons</a></li>

Wyświetl plik

@ -7,7 +7,7 @@
import { getZoomDestinationForLine } from "../../utils/zoom";
import RouteForm from "../route-form/route-form.vue";
import vTooltip from "../../utils/tooltip";
import { formatField, formatRouteMode, formatTime, normalizeLineName, round } from "facilmap-utils";
import { formatField, formatRouteTime, normalizeLineName, round } from "facilmap-utils";
import { computed, ref } from "vue";
import { useToasts } from "../ui/toasts/toasts.vue";
import { showConfirm } from "../ui/alert.vue";
@ -160,7 +160,7 @@
<div class="fm-search-box-collapse-point" v-if="!isMoving">
<dl class="fm-search-box-dl">
<dt class="distance">Distance</dt>
<dd class="distance">{{round(line.distance, 2)}}&#x202F;km <span v-if="line.time != null">({{formatTime(line.time)}}&#x202F;h {{formatRouteMode(line.mode)}})</span></dd>
<dd class="distance">{{round(line.distance, 2)}}&#x202F;km <span v-if="line.time != null">({{formatRouteTime(line.time, line.mode)}})</span></dd>
<template v-if="line.ascent != null">
<dt class="elevation">Climb/drop</dt>

Wyświetl plik

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, markRaw, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import Icon from "../ui/icon.vue";
import { formatRouteMode, formatTime, isSearchId, normalizeMarkerName, round, splitRouteQuery } from "facilmap-utils";
import { formatRouteTime, isSearchId, normalizeMarkerName, round, splitRouteQuery } from "facilmap-utils";
import { useToasts } from "../ui/toasts/toasts.vue";
import type { ExportFormat, FindOnMapResult, SearchResult } from "facilmap-types";
import { getMarkerIcon, type HashQuery, MarkerLayer, RouteLayer } from "facilmap-leaflet";
@ -670,7 +670,7 @@
<dl class="fm-search-box-dl">
<dt>Distance</dt>
<dd>{{round(routeObj.distance, 2)}}&#x202F;km <span v-if="routeObj.time != null">({{formatTime(routeObj.time)}}&#x202F;h {{formatRouteMode(routeObj.mode)}})</span></dd>
<dd>{{round(routeObj.distance, 2)}}&#x202F;km <span v-if="routeObj.time != null">({{formatRouteTime(routeObj.time, routeObj.mode)}})</span></dd>
<template v-if="routeObj.ascent != null">
<dt>Climb/drop</dt>

Wyświetl plik

@ -5,6 +5,9 @@
import type { ThemeColour } from "../../utils/bootstrap";
import { useUnloadHandler } from "../../utils/utils";
import AttributePreservingElement from "./attribute-preserving-element.vue";
import { useI18n } from "../../utils/i18n";
const { t } = useI18n();
const props = withDefaults(defineProps<{
title?: string;
@ -108,7 +111,7 @@
@click="modal.hide()"
type="button"
class="btn-close"
aria-label="Close"
:aria-label="t('modal-dialog.close')"
></button>
</div>
<div class="modal-body">
@ -125,7 +128,7 @@
class="btn btn-secondary"
@click="modal.hide()"
:disabled="isSubmitting || props.isBusy"
>Cancel</button>
>{{t('modal-dialog.cancel')}}</button>
<button
type="submit"
@ -134,7 +137,7 @@
:disabled="isSubmitting || props.isBusy"
>
<div v-if="isSubmitting" class="spinner-border spinner-border-sm"></div>
{{props.okLabel ?? (isCloseButton ? 'Close' : 'Save')}}
{{props.okLabel ?? (isCloseButton ? t('modal-dialog.close') : t('modal-dialog.save'))}}
</button>
</div>
</ValidatedForm>

Wyświetl plik

@ -0,0 +1,73 @@
/// <reference types="vite/client" />
import { type i18n } from "i18next";
import { defineComponent, ref } from "vue";
import messagesEn from "../../i18n/en";
import messagesDe from "../../i18n/de";
import { getRawI18n, onI18nReady } from "facilmap-utils";
const namespace = "facilmap-frontend";
onI18nReady((i18n) => {
i18n.addResourceBundle("en", namespace, messagesEn);
i18n.addResourceBundle("de", namespace, messagesDe);
});
if (import.meta.hot) {
import.meta.hot.accept("../../i18n/en", (m) => {
onI18nReady((i18n) => {
i18n.addResourceBundle("en", namespace, m!.default);
});
});
import.meta.hot.accept("../../i18n/de", (m) => {
onI18nReady((i18n) => {
i18n.addResourceBundle("de", namespace, m!.default);
});
});
}
const rerenderCounter = ref(0);
const rerender = () => {
rerenderCounter.value++;
};
onI18nReady((i18n) => {
i18n.store.on("added", rerender);
i18n.store.on("removed", rerender);
i18n.on("languageChanged", rerender);
i18n.on("loaded", rerender);
});
export function useI18n(): Pick<i18n, "t"> {
return {
t: new Proxy(getRawI18n().getFixedT(null, namespace), {
apply: (target, thisArg, argumentsList) => {
rerenderCounter.value;
return target.apply(thisArg, argumentsList as any);
}
})
};
}
export const T = defineComponent({
props: {
k: { type: String, required: true }
},
setup(props, { slots }) {
const i18n = useI18n();
return () => {
const mappedSlots = Object.entries(slots).map(([name, slot], i) => ({ name, placeholder: `%___SLOT_${i}___%`, slot }));
const placeholderByName = Object.fromEntries(mappedSlots.map(({ name, placeholder }) => [name, placeholder]));
const slotByPlaceholder = Object.fromEntries(mappedSlots.map(({ placeholder, slot }) => [placeholder, slot]));
const message = i18n.t(props.k, placeholderByName);
return message.split(/(%___SLOT_\d+___%)/g).map((v, i) => {
if (i % 2 === 0) {
return v;
} else {
return slotByPlaceholder[v]!();
}
});
};
}
});

Wyświetl plik

@ -5,7 +5,7 @@ import type { SelectedItem } from "./selection";
import type { FindOnMapLine, FindOnMapMarker, FindOnMapResult, Line, Marker, SearchResult } from "facilmap-types";
import type { Geometry } from "geojson";
import { isMapResult } from "./search";
import { decodeLonLatUrl, normalizeLineName, normalizeMarkerName } from "facilmap-utils";
import { decodeLonLatUrl, normalizeLineName, normalizeMarkerName, splitRouteQuery } from "facilmap-utils";
import type { ClientContext } from "../components/facil-map-context-provider/client-context";
import type { FacilMapContext } from "../components/facil-map-context-provider/facil-map-context";
import { requireClientContext, requireMapContext } from "../components/facil-map-context-provider/facil-map-context-provider.vue";
@ -140,10 +140,13 @@ export async function openSpecialQuery(query: string, context: FacilMapContext,
const searchBoxContext = toRef(() => context.components.searchBox);
const routeFormTabContext = toRef(() => context.components.routeFormTab);
if(searchBoxContext.value && routeFormTabContext.value && query.match(/ to /i)) {
routeFormTabContext.value.setQuery(query, zoom, smooth);
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`, { autofocus: true });
return true;
if(searchBoxContext.value && routeFormTabContext.value) {
const split = splitRouteQuery(query);
if (split.queries.length >= 2) {
routeFormTabContext.value.setQuery(query, zoom, smooth);
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`, { autofocus: true });
return true;
}
}
const lonlat = decodeLonLatUrl(query);

Wyświetl plik

@ -43,6 +43,7 @@
"cheerio": "^1.0.0-rc.12",
"compression": "^1.7.4",
"compressjs": "^1.0.3",
"cookie-parser": "^1.4.6",
"csv-stringify": "^6.4.6",
"dotenv": "^16.4.5",
"ejs": "^3.1.9",
@ -53,6 +54,8 @@
"facilmap-types": "workspace:^",
"facilmap-utils": "workspace:^",
"find-cache-dir": "^5.0.0",
"i18next": "^23.10.1",
"i18next-http-middleware": "^3.5.0",
"lodash-es": "^4.17.21",
"maxmind": "^4.3.18",
"md5-file": "^5.0.0",
@ -69,6 +72,7 @@
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.7",
"@types/debug": "^4.1.12",
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21",

66
server/src/i18n.ts 100644
Wyświetl plik

@ -0,0 +1,66 @@
import { defaultRawI18nGetter, getRawI18n, onI18nReady, setLanguageDetector, setRawI18nGetter } from "facilmap-utils";
import messagesEn from "./i18n/en";
import messagesDe from "facilmap-utils/src/i18n/de";
import type { i18n } from "i18next";
import type { Domain } from "domain";
import type { RequestHandler } from "express";
import i18nextHttpMiddleware from "i18next-http-middleware";
const namespace = "facilmap-server";
declare global {
namespace NodeJS {
interface Process {
domain?: Domain;
}
}
}
declare module 'domain' {
interface Domain {
facilmap?: {
i18n?: i18n
}
}
}
onI18nReady((i18n) => {
i18n.addResourceBundle("en", namespace, messagesEn);
i18n.addResourceBundle("de", namespace, messagesDe);
});
setLanguageDetector(i18nextHttpMiddleware.LanguageDetector);
export const i18nMiddleware: RequestHandler[] = [
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
(req, res, next) => {
i18nextHttpMiddleware.handle(getRawI18n())(req, res, next);
},
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
(req, res, next) => {
if ((req as any).i18n) {
if (!process.domain!.facilmap) {
process.domain!.facilmap = {};
}
process.domain!.facilmap.i18n = (req as any).i18n;
}
next();
}
];
setRawI18nGetter(() => {
if (process.domain?.facilmap?.i18n) {
return process.domain?.facilmap?.i18n;
} else {
return defaultRawI18nGetter();
}
});
export function getI18n(): Pick<i18n, "t"> {
return {
t: getRawI18n().getFixedT(null, namespace)
};
}

Wyświetl plik

@ -0,0 +1,4 @@
const messagesEn = {
};
export default messagesEn;

Wyświetl plik

@ -0,0 +1,4 @@
const messagesEn = {
};
export default messagesEn;

Wyświetl plik

@ -14,6 +14,8 @@ import { paths } from "facilmap-frontend/build.js";
import config from "./config";
import { exportCsv } from "./export/csv.js";
import * as z from "zod";
import cookieParser from "cookie-parser";
import { i18nMiddleware } from "./i18n.js";
function getBaseUrl(req: Request): string {
return config.baseUrl ?? `${req.protocol}://${req.host}/`;
@ -67,6 +69,9 @@ export async function initWebserver(database: Database, port: number, host?: str
app.use(domainMiddleware);
app.use(compression());
app.use(cookieParser());
app.use(i18nMiddleware);
app.get("/", padMiddleware);

Wyświetl plik

@ -38,6 +38,8 @@
"dompurify": "^3.0.9",
"facilmap-types": "workspace:^",
"filtrex": "^2.2.3",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.2.1",
"jquery": "^3.7.1",
"jsdom": "^24.0.0",
"linkify-string": "^4.1.3",

Wyświetl plik

@ -1,5 +1,52 @@
import { expect, test } from "vitest";
import { matchLonLat } from "../search";
import { matchLonLat, splitRouteQuery } from "../search";
test("splitRouteQuery", () => {
expect(splitRouteQuery("Hamburg to Berlin")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: null
});
expect(splitRouteQuery("from Hamburg to Berlin")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: null
});
expect(splitRouteQuery("to Berlin from Hamburg")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: null
});
expect(splitRouteQuery("Hamburg to Berlin via Hannover")).toEqual({
queries: ["Hamburg", "Hannover", "Berlin"],
mode: null
});
expect(splitRouteQuery("via Hannover to Berlin from Hamburg")).toEqual({
queries: ["Hamburg", "Hannover", "Berlin"],
mode: null
});
expect(splitRouteQuery("by walk to Berlin from Hamburg")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: "foot"
});
expect(splitRouteQuery("from Hamburg by walk to Berlin")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: "foot"
});
expect(splitRouteQuery("Hamburg to Berlin walking")).toEqual({
queries: ["Hamburg", "Berlin"],
mode: "foot"
});
expect(splitRouteQuery("Hamburg")).toEqual({
queries: ["Hamburg"],
mode: null
});
});
test("matchLonLat", () => {
// Simple coordinates

Wyświetl plik

@ -1,6 +1,7 @@
import type { Point } from "facilmap-types";
import { RetryError, throttledBatch } from "./utils";
import { fetchAdapter, getConfig } from "./config";
import { RetryError, throttledBatch } from "./utils.js";
import { fetchAdapter, getConfig } from "./config.js";
import { getI18n } from "./i18n.js";
const MAX_DELAY_MS = 60_000;
@ -18,7 +19,7 @@ export const getElevationForPoint = throttledBatch<[Point], number | undefined>(
})
});
if (!res.ok) {
let error = new Error(`Looking up elevations failed with status ${res.status}.`);
let error = new Error(getI18n().t("elevation.http-error", { status: res.status }));
if (res.status === 504) {
// Probably caused by an overload on the server. Usually it goes away after a while. Let's exponentially increase delays
// between requests until it succeeds again.

Wyświetl plik

@ -6,6 +6,7 @@ import createPurify from "dompurify";
import { type Cheerio, load } from "cheerio";
import { normalizeFieldValue } from "./objects.js";
import { NodeWithChildren, Element, type Node, type ParentNode, Text, type AnyNode } from "domhandler";
import { getI18n } from "./i18n.js";
const purify = createPurify(typeof window !== "undefined" ? window : new (await import("jsdom")).JSDOM("").window);
@ -115,7 +116,7 @@ export function formatTime(seconds: number): string {
let minutes: string | number = Math.floor((seconds%3600)/60);
if(minutes < 10)
minutes = "0" + minutes;
return hours + ":" + minutes;
return getI18n().t("format.time", { hours, minutes });
}
function applyMarkdownModifications($el: Cheerio<AnyNode>): void {

Wyświetl plik

@ -0,0 +1,61 @@
import i18next, { type Module, type Newable, type i18n } from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
let hasBeenUsed = false;
const onFirstUse: Array<(i18n: i18n) => void> = [];
let languageDetector: Module | Newable<Module> | undefined = typeof window !== "undefined" ? LanguageDetector : undefined;
export function setLanguageDetector(detector: Module | Newable<Module> | undefined): void {
languageDetector = detector;
}
export const defaultRawI18nGetter = (): i18n => {
// Initialize i18next on first usage if it is not initialized yet.
// In apps that use i18next, this leaves enough time for them to initialize it with their own custom settings.
// In apps that don't use i18next, this allows them to use our functions without having to care about initializing it.
if (!i18next.isInitializing && !i18next.isInitialized) {
if (languageDetector) {
i18next.use(languageDetector);
}
void i18next.init({
initImmediate: false,
...(languageDetector ? {} : { lng: "en" }),
fallbackLng: "en"
});
}
return i18next;
};
let rawI18nGetter = defaultRawI18nGetter;
export function setRawI18nGetter(getter: () => i18n): void {
rawI18nGetter = getter;
}
// Should be called by an individual getI18n() function in each package that makes sure that its translations are loaded
export function getRawI18n(): i18n {
const i18n = rawI18nGetter();
if (!hasBeenUsed) {
for (const callback of onFirstUse) {
callback(i18n);
}
hasBeenUsed = true;
}
return i18n;
}
/**
* Calls the given callback the first time getI18n() is called. If getI18n() has already been called, calls the callback immediately.
*/
export function onI18nReady(callback: (i18n: i18n) => void): void {
if (hasBeenUsed) {
callback(getRawI18n());
} else {
onFirstUse.push(callback);
}
}

32
utils/src/i18n.ts 100644
Wyświetl plik

@ -0,0 +1,32 @@
/// <reference types="vite/client" />
import type { i18n } from "i18next";
import { getRawI18n, onI18nReady } from "./i18n-utils";
import messagesDe from "./i18n/de";
import messagesEn from "./i18n/en";
const namespace = "facilmap-utils";
onI18nReady((i18n) => {
i18n.addResourceBundle("en", namespace, messagesEn);
i18n.addResourceBundle("de", namespace, messagesDe);
});
if (import.meta.hot) {
import.meta.hot.accept("./i18n/en", (m) => {
onI18nReady((i18n) => {
i18n.addResourceBundle("en", namespace, m!.default);
});
});
import.meta.hot.accept("./i18n/de", (m) => {
onI18nReady((i18n) => {
i18n.addResourceBundle("de", namespace, m!.default);
});
});
}
export function getI18n(): Pick<i18n, "t"> {
return {
t: getRawI18n().getFixedT(null, namespace)
};
}

Wyświetl plik

@ -0,0 +1,50 @@
const messagesDe = {
"elevation": {
"http-error": `Unerwarteter Fehler beim Laden der Höhe über NN (Status {{status}}).`
},
"format": {
"time": `{{hours}}:{{minutes}}`
},
"objects": {
"untitled-marker": `Unbenannter Marker`,
"untitled-lined": `Unbenannte Linie`
},
"pads": {
"unnamed-map": `Unbenannte Karte`,
"page-title": `{{padName}} – {{appName}}`,
"fallback-page-description": `Eine vielseitige Karte auf OpenStreetMap-Basis, auf der Marker und Linien mit Live-Kollaboration gesetzt werden können.`
},
"routing": {
"route-mode-hgv": `mit dem LKW`,
"route-mode-car": `mit dem Auto`,
"route-mode-road-bike": `mit dem Rennrad`,
"route-mode-mountain-bike": `mit dem Mountain Bike`,
"route-mode-electric-bike": `mit dem E-Bike`,
"route-mode-bicycle": `mit dem Fahrrad`,
"route-mode-wheelchair": `mit dem Rollstuhl`,
"route-mode-foot": `zu Fuß`,
"route-time": `{{time}}\u202Fh {{mode}}`
},
"search": {
"http-error": `Unerwarteter Fehler bei der Suche (Status {{status}}).`,
"from": `von`,
"to": `nach`,
"via": `über`,
"hgv": `mit dem LKW`,
"car": `mit dem Auto`,
"road-bike": `mit dem Rennrad`,
"mountain-bike": `mit dem Mountain Bike`,
"electric-bike": `mit dem E-Bike|mit dem Pedelec`,
"bicycle": `mit dem Rad|mit dem Fahrrad`,
"wheelchair": `mit dem Rollstuhl`,
"foot": `zu Fuß`,
"straight": `Luftlinie`
}
};
export default messagesDe;

Wyświetl plik

@ -0,0 +1,50 @@
const messagesEn = {
"elevation": {
"http-error": `Looking up elevations failed with status {{status}}.`
},
"format": {
"time": `{{hours}}:{{minutes}}`
},
"objects": {
"untitled-marker": `Untitled marker`,
"untitled-lined": `Untitled line`
},
"pads": {
"unnamed-map": `Unnamed map`,
"page-title": `{{padName}} – {{appName}}`,
"fallback-page-description": `A fully-featured OpenStreetMap-based map where markers and lines can be added with live collaboration.`
},
"routing": {
"route-mode-hgv": `by HGV`,
"route-mode-car": `by car`,
"route-mode-road-bike": `by road bike`,
"route-mode-mountain-bike": `by mountain bike`,
"route-mode-electric-bike": `by electric bike`,
"route-mode-bicycle": `by bicycle`,
"route-mode-wheelchair": `by wheelchair`,
"route-mode-foot": `on foot`,
"route-time": `{{time}}\u202Fh {{mode}}`
},
"search": {
"http-error": `Search failed with status {{status}}.`,
"from": `from`,
"to": `to`,
"via": `via`,
"hgv": `by HGV`,
"car": `by car`,
"road-bike": `by road bike`,
"mountain-bike": `by mountain bike`,
"electric-bike": `by electric bike`,
"bicycle": `by bicycle|by bike`,
"wheelchair": `by wheelchair`,
"foot": `on foot|by walk|by walking|walking`,
"straight": `straight|by helicopter`
}
};
export default messagesEn;

Wyświetl plik

@ -2,6 +2,7 @@ export * from "./config.js";
export * from "./elevation.js";
export * from "./filter.js";
export * from "./format.js";
export * from "./i18n-utils.js";
export * from "./objects.js";
export * from "./pads.js";
export * from "./routing.js";

Wyświetl plik

@ -1,5 +1,6 @@
import { lineValidator, markerValidator, type CRU, type Field, type FieldOption, type Line, type LineTemplate, type Marker, type Type } from "facilmap-types";
import { omit } from "lodash-es";
import { getI18n } from "./i18n.js";
export function isMarker<Mode extends CRU.READ | CRU.CREATE>(object: Marker<Mode> | Line<Mode>): object is Marker<Mode> {
return "lat" in object && object.lat != null;
@ -168,9 +169,9 @@ export function getLineTemplate(type: Type): LineTemplate {
}
export function normalizeMarkerName(name: string | undefined): string {
return name || "Untitled marker";
return name || getI18n().t("objects.untitled-marker");
}
export function normalizeLineName(name: string | undefined): string {
return name || "Untitled line";
return name || getI18n().t("objects.untitled-line");
}

Wyświetl plik

@ -1,4 +1,5 @@
import type { ID, Type, View } from "facilmap-types";
import { getI18n } from "./i18n.js";
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const LENGTH = 12;
@ -12,15 +13,15 @@ export function generateRandomPadId(length: number = LENGTH): string {
}
export function normalizePadName(name: string | undefined): string {
return name || "Unnamed map";
return name || getI18n().t("pads.unnamed-map");
}
export function normalizePageTitle(padName: string | undefined, appName: string): string {
return `${padName ? `${padName}` : ''}${appName}`;
return padName ? getI18n().t("pads.page-title", { padName, appName }) : appName;
}
export function normalizePageDescription(padDescription: string | undefined): string {
return padDescription || "A fully-featured OpenStreetMap-based map where markers and lines can be added with live collaboration.";
return padDescription || getI18n().t("pads.fallback-page-description");
}
export function getOrderedTypes(types: Type[] | Record<ID, Type>): Type[] {

Wyświetl plik

@ -1,4 +1,6 @@
import type { RouteMode } from "facilmap-types";
import { getI18n } from "./i18n.js";
import { formatTime } from "./format.js";
export interface DecodedRouteMode {
mode: "" | "car" | "bicycle" | "pedestrian" | "track";
@ -92,29 +94,36 @@ export function formatRouteMode(encodedMode: RouteMode): string {
case "car":
switch(decodedMode.type) {
case "hgv":
return "by HGV";
return getI18n().t("routing.route-mode-hgv");
default:
return "by car";
return getI18n().t("routing.route-mode-car");
}
case "bicycle":
switch(decodedMode.type) {
case "road":
return "by road bike";
return getI18n().t("routing.route-mode-road-bike");
case "mountain":
return "by mountain bike";
return getI18n().t("routing.route-mode-mountain-bike");
case "electric":
return "by electric bike";
return getI18n().t("routing.route-mode-electric-bike");
default:
return "by bicycle";
return getI18n().t("routing.route-mode-bicycle");
}
case "pedestrian":
switch(decodedMode.type) {
case "wheelchair":
return "by wheelchair";
return getI18n().t("routing.route-mode-wheelchair");
default:
return "on foot";
return getI18n().t("routing.route-mode-foot");
}
default:
return "";
}
}
export function formatRouteTime(time: number, encodedMode: RouteMode): string {
return getI18n().t("routing.route-time", {
time: formatTime(time),
mode: formatRouteMode(encodedMode)
});
}

Wyświetl plik

@ -1,8 +1,10 @@
import type { Point, SearchResult, ZoomLevel } from "facilmap-types";
import throttle from "p-throttle";
import type { Geometry } from "geojson";
import { formatCoordinates } from "./format";
import { fetchAdapter, getConfig } from "./config";
import { formatCoordinates } from "./format.js";
import { fetchAdapter, getConfig } from "./config.js";
import { getI18n } from "./i18n.js";
import { quoteRegExp } from "./utils.js";
interface NominatimResult {
place_id: number;
@ -77,22 +79,56 @@ interface PointWithZoom extends Point {
const throttledFetch = throttle({ limit: 1, interval: 1000 })((...args: Parameters<typeof fetchAdapter>) => fetchAdapter(...args));
export function splitRouteQuery(query: string): { queries: string[], mode: string | null } {
const splitQuery = query.split(/(^|\s+)(from|to|via|by)(\s+|$)/).filter((item, i) => (i%2 == 0)); // Filter out every second item (whitespace parantheses)
const i18n = getI18n();
const routeModesByTranslation = Object.fromEntries(Object.entries({
[i18n.t("search.hgv")]: "hgv",
[i18n.t("search.car")]: "car",
[i18n.t("search.road-bike")]: "road bike",
[i18n.t("search.mountain-bike")]: "mountain bike",
[i18n.t("search.electric-bike")]: "electric bike",
[i18n.t("search.bicycle")]: "bicycle",
[i18n.t("search.wheelchair")]: "wheelchair",
[i18n.t("search.foot")]: "foot",
[i18n.t("search.straight")]: "straight"
}).flatMap(([k, v]) => k.split("|").map((k2) => [k2, v])));
let queryWithoutMode = query;
let mode = null;
for (const [translation, thisMode] of Object.entries(routeModesByTranslation)) {
const m = query.match(new RegExp(`(?<= |^)${quoteRegExp(translation)}(?= |$)`, "di"));
if (m) {
queryWithoutMode = `${query.slice(0, m.indices![0][0])}${query.slice(m.indices![0][1])}`.trim();
mode = thisMode;
break;
}
}
const keywordsByTranslation = Object.fromEntries(Object.entries({
[i18n.t("search.from")]: "from",
[i18n.t("search.to")]: "to",
[i18n.t("search.via")]: "via"
}).flatMap(([k, v]) => k.split("|").map((k2) => [k2, v])));
const splitQuery = queryWithoutMode
.split(new RegExp(`(?:^|\\s+)(from|to|via|${Object.keys(keywordsByTranslation).map((k) => quoteRegExp(k)).join("|")})(?:\\s+|$)`));
const queryParts = {
from: [] as string[],
via: [] as string[],
to: [] as string[],
by: [] as string[]
to: [] as string[]
};
for(let i=0; i<splitQuery.length; i+=2) {
if(splitQuery[i])
queryParts[splitQuery[i-1] as keyof typeof queryParts || "from"].push(splitQuery[i]);
if(splitQuery[i]) {
const thisKeyword = splitQuery[i - 1] ? (keywordsByTranslation[splitQuery[i - 1]] ?? splitQuery[i - 1]) : "from";
queryParts[thisKeyword as keyof typeof queryParts].push(splitQuery[i]);
}
}
return {
queries: queryParts.from.concat(queryParts.via, queryParts.to),
mode: queryParts.by[0] || null
mode
};
}
@ -245,7 +281,7 @@ async function _findLonLat(lonlatWithZoom: PointWithZoom): Promise<Array<SearchR
);
if (!res.ok) {
throw new Error(`Reverse geocoding failed with status ${res.status}`);
throw new Error(getI18n().t("search.http-error", { status: res.status }));
}
const body: NominatimResult | NominatimError = await res.json();
@ -267,7 +303,7 @@ async function _findQuery(query: string): Promise<Array<SearchResult>> {
);
if (!res.ok) {
throw new Error(`Search failed with status ${res.status}.`);
throw new Error(getI18n().t("search.http-error", { status: res.status }));
}
const body: Array<NominatimResult> | NominatimError = await res.json();
@ -281,12 +317,15 @@ async function _findQuery(query: string): Promise<Array<SearchResult>> {
}
async function _findOsmObject(type: string, id: string): Promise<Array<SearchResult>> {
const body: Array<NominatimResult> | NominatimError = await throttledFetch(
const res = await throttledFetch(
`${getConfig().nominatimUrl}/lookup?format=jsonv2&addressdetails=1&polygon_geojson=1&extratags=1&namedetails=1&osm_ids=${encodeURI(type.toUpperCase())}${encodeURI(id)}`
).then((res) => res.json() as any);
);
if(!body)
throw new Error("Invalid response from name finder.");
if (!res.ok) {
throw new Error(getI18n().t("search.http-error", { status: res.status }));
}
const body: Array<NominatimResult> | NominatimError = await res.json();
if('error' in body)
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);

Wyświetl plik

@ -44,6 +44,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.23.2":
version: 7.24.1
resolution: "@babel/runtime@npm:7.24.1"
dependencies:
regenerator-runtime: ^0.14.0
checksum: 5c8f3b912ba949865f03b3cf8395c60e1f4ebd1033fbd835bdfe81b6cac8a87d85bc3c7aded5fcdf07be044c9ab8c818f467abe0deca50020c72496782639572
languageName: node
linkType: hard
"@babel/types@npm:^7.8.3":
version: 7.24.0
resolution: "@babel/types@npm:7.24.0"
@ -878,6 +887,15 @@ __metadata:
languageName: node
linkType: hard
"@types/cookie-parser@npm:^1.4.7":
version: 1.4.7
resolution: "@types/cookie-parser@npm:1.4.7"
dependencies:
"@types/express": "*"
checksum: 7b87c59420598e686a57e240be6e0db53967c3c8814be9326bf86609ee2fc39c4b3b9f2263e1deba43526090121d1df88684b64c19f7b494a80a4437caf3d40b
languageName: node
linkType: hard
"@types/cookie@npm:^0.4.1":
version: 0.4.1
resolution: "@types/cookie@npm:0.4.1"
@ -2476,6 +2494,16 @@ __metadata:
languageName: node
linkType: hard
"cookie-parser@npm:^1.4.6":
version: 1.4.6
resolution: "cookie-parser@npm:1.4.6"
dependencies:
cookie: 0.4.1
cookie-signature: 1.0.6
checksum: 1e5a63aa82e8eb4e02d2977c6902983dee87b02e87ec5ec43ac3cb1e72da354003716570cd5190c0ad9e8a454c9d3237f4ad6e2f16d0902205a96a1c72b77ba5
languageName: node
linkType: hard
"cookie-signature@npm:1.0.6":
version: 1.0.6
resolution: "cookie-signature@npm:1.0.6"
@ -3658,6 +3686,7 @@ __metadata:
file-saver: ^2.0.5
hammerjs: ^2.0.8
happy-dom: ^13.6.2
i18next: ^23.10.1
jquery: ^3.7.1
leaflet: ^1.9.4
leaflet-draggable-lines: ^2.0.0
@ -3769,6 +3798,7 @@ __metadata:
resolution: "facilmap-server@workspace:server"
dependencies:
"@types/compression": ^1.7.5
"@types/cookie-parser": ^1.4.7
"@types/debug": ^4.1.12
"@types/ejs": ^3.1.5
"@types/express": ^4.17.21
@ -3780,6 +3810,7 @@ __metadata:
cheerio: ^1.0.0-rc.12
compression: ^1.7.4
compressjs: ^1.0.3
cookie-parser: ^1.4.6
cpy-cli: ^5.0.0
csv-stringify: ^6.4.6
debug: ^4.3.4
@ -3792,6 +3823,8 @@ __metadata:
facilmap-types: "workspace:^"
facilmap-utils: "workspace:^"
find-cache-dir: ^5.0.0
i18next: ^23.10.1
i18next-http-middleware: ^3.5.0
lodash-es: ^4.17.21
maxmind: ^4.3.18
md5-file: ^5.0.0
@ -3843,6 +3876,8 @@ __metadata:
dompurify: ^3.0.9
facilmap-types: "workspace:^"
filtrex: ^2.2.3
i18next: ^23.10.1
i18next-browser-languagedetector: ^7.2.1
jquery: ^3.7.1
jsdom: ^24.0.0
linkify-string: ^4.1.3
@ -4528,6 +4563,31 @@ __metadata:
languageName: node
linkType: hard
"i18next-browser-languagedetector@npm:^7.2.1":
version: 7.2.1
resolution: "i18next-browser-languagedetector@npm:7.2.1"
dependencies:
"@babel/runtime": ^7.23.2
checksum: 159958be2d8f19444e9378512c36c2bf13a8ab85eddac2fc0000198a03dbc28c73a6f44594ab040b242bdc82dfeabb7c1ab805884b5438ee0a48a8e2b52ca062
languageName: node
linkType: hard
"i18next-http-middleware@npm:^3.5.0":
version: 3.5.0
resolution: "i18next-http-middleware@npm:3.5.0"
checksum: c3669f04efd8967729a50fa06be1c7449612e3f2555815a3399d35dcf9404e35182a12cc68c1ed3bbe5da73d868dbd2ac62553be8c1a7ce0d551018dc1e223fa
languageName: node
linkType: hard
"i18next@npm:^23.10.1":
version: 23.10.1
resolution: "i18next@npm:23.10.1"
dependencies:
"@babel/runtime": ^7.23.2
checksum: 4aec10ddb0bb841f15b9b023daa59977052bc706ca4e94643b12b17640731862bde596c9797491638f6d9e7f125722ea9b1e87394c7aebbb72f45c20396f79d9
languageName: node
linkType: hard
"iconv-lite@npm:0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"