Add better translation support for templates

This commit is contained in:
pietervdvn 2022-09-22 16:21:07 +02:00
parent ee93c70867
commit 55593c2ee3
6 changed files with 250 additions and 115 deletions

View file

@ -3,8 +3,11 @@ import Locale from "../i18n/Locale"
import Link from "./Link"
import Svg from "../../Svg"
/**
* The little 'translate'-icon next to every icon + some static helper functions
*/
export default class LinkToWeblate extends VariableUiElement {
private static URI: any
constructor(context: string, availableTranslations: object) {
super(
Locale.language.map(

View file

@ -21,6 +21,13 @@ import Toggle from "../Input/Toggle";
import List from "../Base/List";
import LeftIndex from "../Base/LeftIndex";
import Constants from "../../Models/Constants";
import Toggleable from "../Base/Toggleable";
import Lazy from "../Base/Lazy";
import LinkToWeblate from "../Base/LinkToWeblate";
import Link from "../Base/Link";
import {SearchablePillsSelector} from "../Input/SearchableMappingsSelector";
import * as languages from "../../assets/language_translations.json"
import {Translation} from "../i18n/Translation";
class SelectTemplate extends Combine implements FlowStep<{ title: string, pages: string[] }> {
readonly IsValid: Store<boolean>;
@ -64,7 +71,7 @@ class SelectTemplate extends Combine implements FlowStep<{ title: string, pages:
}
},
fromX => undefined
_ => undefined
)
elements.push(fileMapped)
const radio = new RadioButton(elements, {selectFirstAsDefault: true})
@ -175,13 +182,23 @@ class PreparePdf extends Combine implements FlowStep<{ svgToPdf: SvgToPdf, langu
new FixedInputElement("Nederlands", "nl"),
new FixedInputElement("English", "en")
]
const languages = new CheckBoxes(languageOptions)
const langs: string[] = Array.from(Object.keys(languages["default"] ?? languages))
console.log("Available languages are:",langs)
const languageSelector = new SearchablePillsSelector(
langs.map(l => ({
show: new Translation(languages[l]),
value: l,
mainTerm: languages[l]
})), {
mode: "select-many"
}
)
const isPrepared = UIEventSource.FromPromiseWithErr(svgToPdf.Prepare())
super([
new Title("Select languages..."),
languages,
languageSelector,
new Toggle(
new Loading("Preparing maps..."),
undefined,
@ -194,19 +211,66 @@ class PreparePdf extends Combine implements FlowStep<{ svgToPdf: SvgToPdf, langu
}
if (isPrepped["success"] !== undefined) {
const svgToPdf = isPrepped["success"]
const langs = languages.GetValue().data.map(i => languageOptions[i].GetValue().data)
const langs = languageSelector.GetValue().data
console.log("Languages are", langs)
if (langs.length === 0) {
return undefined
}
return {svgToPdf, languages: langs}
}
return undefined;
}, [languages.GetValue()])
}, [languageSelector.GetValue()])
this.IsValid = this.Value.map(v => v !== undefined)
}
}
class InspectStrings extends Toggle implements FlowStep<{ svgToPdf: SvgToPdf, languages: string[] }> {
readonly IsValid: Store<boolean>;
readonly Value: Store<{ svgToPdf: SvgToPdf; languages: string[] }>;
constructor(svgToPdf: SvgToPdf, languages: string[]) {
const didLoadLanguages = UIEventSource.FromPromiseWithErr(svgToPdf.PrepareLanguages(languages)).map(l => l !== undefined && l["success"] !== undefined)
super(new Combine([
new Title("Inspect translation strings"),
...languages.map(l => new Lazy(() => InspectStrings.createOverviewPanel(svgToPdf, l)))
]),
new Loading(),
didLoadLanguages
);
this.Value = new ImmutableStore({svgToPdf, languages})
this.IsValid = didLoadLanguages
}
private static createOverviewPanel(svgToPdf: SvgToPdf, language: string): BaseUIElement {
const elements: BaseUIElement[] = []
for (const translationKey of Array.from(svgToPdf.translationKeys())) {
let spec = translationKey
if (translationKey.startsWith("layer.")) {
spec = "layers:" + translationKey.substring(6)
} else {
spec = "core:" + translationKey
}
elements.push(new Combine([
new Link(spec, LinkToWeblate.hrefToWeblate(language, spec), true).SetClass("font-bold link-underline"),
"&nbsp;",
svgToPdf.getTranslation("$" + translationKey, language, true) ?? new FixedUiElement("No translation found!").SetClass("alert")
]))
}
return new Toggleable(
new Title("Translations for " + language),
new Combine(["The following keys are used:",
new List(elements)
]),
{closeOnClick: false, height: "15rem"})
}
}
class SavePdf extends Combine {
@ -242,6 +306,7 @@ export class PdfExportGui extends LeftIndex {
new Title("Select template"), new SelectTemplate()
).then(new Title("Select options"), ({title, pages}) => new SelectPdfOptions(title, pages, createDiv))
.then("Generate maps...", ({title, pages, options}) => new PreparePdf(title, pages, options))
.then("Inspect translations", (({svgToPdf, languages}) => new InspectStrings(svgToPdf, languages)))
.finish("Generating...", ({svgToPdf, languages}) => new SavePdf(svgToPdf, languages))

View file

@ -11,7 +11,7 @@ import Link from "../Base/Link"
import LinkToWeblate from "../Base/LinkToWeblate"
import Toggleable from "../Base/Toggleable"
import Title from "../Base/Title"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import * as native_languages from "../../assets/language_native.json"
@ -89,8 +89,6 @@ class TranslatorsPanelContent extends Combine {
]
}
//
//
// "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
const translated = seed.Subs({
total,

View file

@ -10,14 +10,12 @@ import "../assets/templates/UbuntuMono-B-bold.js"
import {makeAbsolute, parseSVG} from 'svg-path-parser';
import Translations from "../UI/i18n/Translations";
import {Utils} from "../Utils";
import Locale from "../UI/i18n/Locale";
import Constants from "../Models/Constants";
import Hash from "../Logic/Web/Hash";
class SvgToPdfInternals {
private readonly doc: jsPDF;
private static readonly dummyDoc: jsPDF = new jsPDF()
private readonly textSubstitutions: Record<string, string>;
private readonly matrices: Matrix[] = []
private readonly matricesInverted: Matrix[] = []
@ -25,17 +23,14 @@ class SvgToPdfInternals {
private currentMatrixInverted: Matrix;
private readonly _images: Record<string, HTMLImageElement>;
private readonly _layerTranslations: Record<string, Record<string, any>>;
private readonly _rects: Record<string, SVGRectElement>;
private readonly _importedTranslations: Record<string, any>;
private readonly extractTranslation: (string) => string;
constructor(advancedApi: jsPDF, textSubstitutions: Record<string, string>, images: Record<string, HTMLImageElement>, rects: Record<string, SVGRectElement>, importedTranslations: Record<string, any>, layerTranslations: Record<string, Record<string, any>>) {
this._layerTranslations = layerTranslations;
this.textSubstitutions = textSubstitutions;
constructor(advancedApi: jsPDF, images: Record<string, HTMLImageElement>, rects: Record<string, SVGRectElement>, extractTranslation: (string) => string) {
this.doc = advancedApi;
this._images = images;
this._rects = rects;
this._importedTranslations = importedTranslations;
this.extractTranslation = s => extractTranslation(s).replace(/&nbsp;/g, " ");
this.currentMatrix = this.doc.unitMatrix;
this.currentMatrixInverted = this.doc.unitMatrix;
}
@ -220,46 +215,6 @@ class SvgToPdfInternals {
return undefined
}
private extractTranslation(text: string) {
if(text === "$version"){
return new Date().toISOString().substring(0, "2022-01-02THH:MM".length )+" - v"+Constants.vNumber
}
const pathPart = text.match(/\$(([_a-zA-Z0-9?]+\.)+[_a-zA-Z0-9?]+)(.*)/)
if (pathPart === null) {
return text
}
let t: any = Translations.t
const path = pathPart[1].split(".")
if (this._importedTranslations[path[0]]) {
path.splice(0, 1, ...this._importedTranslations[path[0]].split("."))
}
const rest = pathPart[3] ?? ""
if (path[0] === "layer") {
t = this._layerTranslations[Locale.language.data]
if (t === undefined) {
console.error("No layerTranslation available for language " + Locale.language.data)
return text
}
path.splice(0, 1)
}
for (const crumb of path) {
t = t[crumb]
if (t === undefined) {
console.error("No value found to substitute " + text, "the path is", path)
return undefined
}
}
if (typeof t === "string") {
t = new TypedTranslation({"*": t})
}
if (t instanceof TypedTranslation) {
return (<TypedTranslation<any>>t).Subs(this.textSubstitutions).txt + rest
} else {
return (<Translation>t).txt + rest
}
}
private drawTspan(tspan: Element) {
if (tspan.textContent == "") {
return
@ -301,20 +256,19 @@ class SvgToPdfInternals {
let result: string = ""
let addSpace = false
for (let text of textTemplate) {
if(text === "\\n"){
if (text === "\\n") {
result += "\n"
addSpace = false
continue
}
if(text === "\\n\\n"){
if (text === "\\n\\n") {
result += "\n\n"
addSpace = false
continue
}
if (!text.startsWith("$")) {
if(addSpace){
if (addSpace) {
result += " "
}
result += text
@ -337,13 +291,12 @@ class SvgToPdfInternals {
addSpace = false
} else {
const found = this.extractTranslation(text) ?? text
if(addSpace){
if (addSpace) {
result += " "
}
result += found
addSpace = true
}
}
this.doc.text(result, x, y, {
maxWidth,
@ -458,9 +411,6 @@ class SvgToPdfInternals {
}
public handleElement(element: SVGSVGElement | Element): void {
if(element.id === "path15616"){
console.log("Handling element", element)
}
const isTransformed = this.setTransform(element)
try {
@ -534,7 +484,7 @@ export interface SvgToPdfOptions {
disableMaps?: false | true
textSubstitutions?: Record<string, string>,
beforePage?: (i: number) => void,
overrideLocation?: {lat: number, lon: number}
overrideLocation?: { lat: number, lon: number }
}
@ -590,6 +540,27 @@ export class SvgToPdfPage {
})
}
public extractTranslations(): Set<string> {
const textContents: string[] = Array.from(this._svgRoot.getElementsByTagName("tspan"))
.map(t => t.textContent)
const translations = new Set<string>()
console.log("Extracting translations, contents are", textContents)
for (const tc of textContents) {
const parts = tc.split(" ").filter(p => p.startsWith("$") && p.indexOf("(") < 0)
for (let part of parts) {
part = part.substring(1) // Drop the $
let path = part.split(".")
const importPath = this.importedTranslations[path[0]]
if (importPath) {
translations.add(importPath + "." + path.slice(1).join("."))
} else {
translations.add(part)
}
}
}
console.log("Translations keys are", translations)
return translations
}
public async prepareElement(element: SVGSVGElement | Element, mapTextSpecs: SVGTSpanElement[]): Promise<void> {
if (element.tagName === "rect") {
@ -615,7 +586,6 @@ export class SvgToPdfPage {
}
if (element.textContent.startsWith("$map(")) {
mapTextSpecs.push(<any>element)
}
}
}
@ -685,7 +655,7 @@ export class SvgToPdfPage {
console.error("Could not show map with parameters", params)
throw "Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. "
}
layout.widenFactor = 0
layout.widenFactor = 0
layout.overpassTimeout = 600
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
@ -705,16 +675,16 @@ export class SvgToPdfPage {
const zoom = Number(params["zoom"] ?? params["z"] ?? 14);
Hash.hash.setData(undefined)
// QueryParameters.ClearAll()
// QueryParameters.ClearAll()
const state = new FeaturePipelineState(layout)
state.locationControl.setData({
zoom,
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842)
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842)
})
console.log("Params are", params, params["layers"]==="none")
console.log("Params are", params, params["layers"] === "none")
const fl = state.filteredLayers.data
for (const filteredLayer of fl) {
@ -741,7 +711,7 @@ export class SvgToPdfPage {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback(isDisplayed => {
if(!isDisplayed){
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
@ -775,12 +745,11 @@ export class SvgToPdfPage {
textElement.parentElement.removeChild(textElement)
}
public async PrepareLanguage(language: string){
public async PrepareLanguage(language: string) {
// Always fetch the remote data - it's cached anyway
this.layerTranslations[language] = await Utils.downloadJsonCached("https://raw.githubusercontent.com/pietervdvn/MapComplete/develop/langs/layers/" + language + ".json", 24 * 60 * 60 * 1000)
const shared_questions = await Utils.downloadJsonCached("https://raw.githubusercontent.com/pietervdvn/MapComplete/develop/langs/shared-questions/" + language + ".json", 24 * 60 * 60 * 1000)
this.layerTranslations[language]["shared-questions"] = shared_questions["shared_questions"]
}
public async Prepare() {
@ -801,7 +770,7 @@ export class SvgToPdfPage {
}
public drawPage(advancedApi: jsPDF, i: number): void {
public drawPage(advancedApi: jsPDF, i: number, language): void {
if (!this._isPrepared) {
throw "Run 'Prepare()' first!"
}
@ -809,20 +778,79 @@ export class SvgToPdfPage {
if (this.options.beforePage) {
this.options.beforePage(i)
}
const internal = new SvgToPdfInternals(advancedApi, this.options.textSubstitutions, this.images, this.rects, this.importedTranslations, this.layerTranslations);
const self = this
const internal = new SvgToPdfInternals(advancedApi, this.images, this.rects, key => self.extractTranslation(key, language));
for (let child of Array.from(this._svgRoot.children)) {
internal.handleElement(<any>child)
}
}
extractTranslation(text: string, language: string, strict: boolean = false) {
if (text === "$version") {
return new Date().toISOString().substring(0, "2022-01-02THH:MM".length) + " - v" + Constants.vNumber
}
const pathPart = text.match(/\$(([_a-zA-Z0-9? ]+\.)+[_a-zA-Z0-9? ]+)(.*)/)
if (pathPart === null) {
return text
}
let t: any = Translations.t
const path = pathPart[1].split(".")
if (this.importedTranslations[path[0]]) {
path.splice(0, 1, ...this.importedTranslations[path[0]].split("."))
}
const rest = pathPart[3] ?? ""
if (path[0] === "layer") {
t = this.layerTranslations[language]
if (t === undefined) {
console.error("No layerTranslation available for language " + language)
return text
}
path.splice(0, 1)
}
for (const crumb of path) {
t = t[crumb]
if (t === undefined) {
console.error("No value found to substitute " + text, "the path is", path)
return undefined
}
}
if (typeof t === "string") {
t = new TypedTranslation({"*": t})
}
if (t instanceof TypedTranslation) {
if (strict && t.translations[language] === undefined) {
return undefined
}
return t.Subs(this.options.textSubstitutions).textFor(language) + rest
} else if (t instanceof Translation) {
if (strict && t.translations[language] === undefined) {
return undefined
}
return (<Translation>t).textFor(language) + rest
} else {
console.error("Could not get textFor from ", t, "for path", text)
}
}
}
export class SvgToPdf {
public static readonly templates : Record<string, {pages: string[], description: string | Translation}>= {
flyer_a4:{pages: ["/assets/templates/MapComplete-flyer.svg","/assets/templates/MapComplete-flyer.back.svg"], description: Translations.t.flyer.description},
poster_a3: {pages: ["/assets/templates/MapComplete-poster-a3.svg"], description: "A basic A3 poster (similar to the flyer)"},
poster_a2: {pages: ["/assets/templates/MapComplete-poster-a2.svg"], description: "A basic A2 poster (similar to the flyer); scaled up from the A3 poster"}
public static readonly templates: Record<string, { pages: string[], description: string | Translation }> = {
flyer_a4: {
pages: ["/assets/templates/MapComplete-flyer.svg", "/assets/templates/MapComplete-flyer.back.svg"],
description: Translations.t.flyer.description
},
poster_a3: {
pages: ["/assets/templates/MapComplete-poster-a3.svg"],
description: "A basic A3 poster (similar to the flyer)"
},
poster_a2: {
pages: ["/assets/templates/MapComplete-poster-a2.svg"],
description: "A basic A2 poster (similar to the flyer); scaled up from the A3 poster"
}
}
private readonly _title: string;
@ -833,7 +861,7 @@ export class SvgToPdf {
options = options ?? <SvgToPdfOptions>{}
options.textSubstitutions = options.textSubstitutions ?? {}
const mapCount = "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length;
options.textSubstitutions["mapCount"] = mapCount
options.textSubstitutions["mapCount"] = mapCount
this._pages = pages.map(page => new SvgToPdfPage(page, options))
}
@ -851,7 +879,6 @@ export class SvgToPdf {
await page.PrepareLanguage(language)
}
Locale.language.setData(language)
const doc = new jsPDF(mode, undefined, [width, height])
doc.advancedAPI(advancedApi => {
for (let i = 0; i < this._pages.length; i++) {
@ -868,17 +895,50 @@ export class SvgToPdf {
const sy = mediabox.topRightY / targetHeight
advancedApi.setCurrentTransformationMatrix(advancedApi.Matrix(sx, 0, 0, -sy, 0, mediabox.topRightY))
}
this._pages[i].drawPage(advancedApi, i)
this._pages[i].drawPage(advancedApi, i, language)
}
})
await doc.save(this._title+"."+language+".pdf");
await doc.save(this._title + "." + language + ".pdf");
}
public translationKeys(): Set<string> {
const allTranslations = this._pages[0].extractTranslations()
for (let i = 1; i < this._pages.length; i++) {
const translations = this._pages[i].extractTranslations()
translations.forEach(t => allTranslations.add(t))
}
allTranslations.delete("import")
allTranslations.delete("version")
return allTranslations
}
/**
* Prepares all the minimaps
* @constructor
*/
public async Prepare(): Promise<SvgToPdf> {
for (const page of this._pages) {
await page.Prepare()
}
return this
}
public async PrepareLanguages(languages: string[]): Promise<boolean> {
for (const page of this._pages) {
// Load all languages at once.
// We don't parallelize the pages, as they'll probably reload the same languages anyway (and they are cached)
await Promise.all(languages.map(async language => await page.PrepareLanguage(language)))
}
return true
}
getTranslation(translationKey: string, language: string, strict: boolean = false) {
for (const page of this._pages) {
const tr = page.extractTranslation(translationKey, language, strict)
if (tr !== undefined && tr !== translationKey) {
return tr
}
}
return undefined
}
}

View file

@ -728,10 +728,6 @@ video {
margin: 0.25rem;
}
.m-2 {
margin: 0.5rem;
}
.m-4 {
margin: 1rem;
}
@ -740,6 +736,10 @@ video {
margin: 1.25rem;
}
.m-2 {
margin: 0.5rem;
}
.m-0\.5 {
margin: 0.125rem;
}
@ -1028,10 +1028,6 @@ video {
width: 100%;
}
.w-96 {
width: 24rem;
}
.w-24 {
width: 6rem;
}
@ -1082,6 +1078,10 @@ video {
width: max-content;
}
.w-96 {
width: 24rem;
}
.w-32 {
width: 8rem;
}
@ -1313,10 +1313,6 @@ video {
border-radius: 9999px;
}
.rounded-xl {
border-radius: 0.75rem;
}
.rounded-3xl {
border-radius: 1.5rem;
}
@ -1333,6 +1329,10 @@ video {
border-radius: 0.5rem;
}
.rounded-xl {
border-radius: 0.75rem;
}
.rounded-sm {
border-radius: 0.125rem;
}
@ -1342,14 +1342,14 @@ video {
border-bottom-left-radius: 0.25rem;
}
.border-2 {
border-width: 2px;
}
.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}
.border-4 {
border-width: 4px;
}
@ -1366,16 +1366,16 @@ video {
border-bottom-width: 1px;
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0 / var(--tw-border-opacity));
}
.border-gray-500 {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity));
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0 / var(--tw-border-opacity));
}
.border-gray-400 {
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
@ -1489,10 +1489,6 @@ video {
padding-right: 1rem;
}
.pl-4 {
padding-left: 1rem;
}
.pl-1 {
padding-left: 0.25rem;
}
@ -1505,6 +1501,10 @@ video {
padding-bottom: 3rem;
}
.pl-4 {
padding-left: 1rem;
}
.pl-2 {
padding-left: 0.5rem;
}
@ -1533,6 +1533,10 @@ video {
padding-top: 0px;
}
.pr-2 {
padding-right: 0.5rem;
}
.pl-5 {
padding-left: 1.25rem;
}
@ -1557,10 +1561,6 @@ video {
padding-top: 0.125rem;
}
.pr-2 {
padding-right: 0.5rem;
}
.pl-6 {
padding-left: 1.5rem;
}
@ -2042,6 +2042,11 @@ input[type="range"].vertical {
text-decoration: underline 1px var(--foreground-color);
}
a.link-underline {
-webkit-text-decoration: underline 1px var(--foreground-color);
text-decoration: underline 1px var(--foreground-color);
}
.link-no-underline a {
text-decoration: none;
}

View file

@ -288,6 +288,10 @@ input[type="range"].vertical {
text-decoration: underline 1px var(--foreground-color);
}
a.link-underline {
text-decoration: underline 1px var(--foreground-color);
}
.link-no-underline a {
text-decoration: none;
}