diff --git a/UI/Base/LinkToWeblate.ts b/UI/Base/LinkToWeblate.ts index bb078ed56..646c57f83 100644 --- a/UI/Base/LinkToWeblate.ts +++ b/UI/Base/LinkToWeblate.ts @@ -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( diff --git a/UI/BigComponents/PdfExportGui.ts b/UI/BigComponents/PdfExportGui.ts index 67440f499..b249dad86 100644 --- a/UI/BigComponents/PdfExportGui.ts +++ b/UI/BigComponents/PdfExportGui.ts @@ -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; @@ -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; + 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"), + " ", + 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)) diff --git a/UI/BigComponents/TranslatorsPanel.ts b/UI/BigComponents/TranslatorsPanel.ts index c6b1faaf3..ab797d7cc 100644 --- a/UI/BigComponents/TranslatorsPanel.ts +++ b/UI/BigComponents/TranslatorsPanel.ts @@ -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, diff --git a/Utils/svgToPdf.ts b/Utils/svgToPdf.ts index 4172d8ebb..d7f793b18 100644 --- a/Utils/svgToPdf.ts +++ b/Utils/svgToPdf.ts @@ -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; private readonly matrices: Matrix[] = [] private readonly matricesInverted: Matrix[] = [] @@ -25,17 +23,14 @@ class SvgToPdfInternals { private currentMatrixInverted: Matrix; private readonly _images: Record; - private readonly _layerTranslations: Record>; private readonly _rects: Record; - private readonly _importedTranslations: Record; + private readonly extractTranslation: (string) => string; - constructor(advancedApi: jsPDF, textSubstitutions: Record, images: Record, rects: Record, importedTranslations: Record, layerTranslations: Record>) { - this._layerTranslations = layerTranslations; - this.textSubstitutions = textSubstitutions; + constructor(advancedApi: jsPDF, images: Record, rects: Record, extractTranslation: (string) => string) { this.doc = advancedApi; this._images = images; this._rects = rects; - this._importedTranslations = importedTranslations; + this.extractTranslation = s => extractTranslation(s).replace(/ /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 (>t).Subs(this.textSubstitutions).txt + rest - } else { - return (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, beforePage?: (i: number) => void, - overrideLocation?: {lat: number, lon: number} + overrideLocation?: { lat: number, lon: number } } @@ -590,6 +540,27 @@ export class SvgToPdfPage { }) } + public extractTranslations(): Set { + const textContents: string[] = Array.from(this._svgRoot.getElementsByTagName("tspan")) + .map(t => t.textContent) + const translations = new Set() + 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 { if (element.tagName === "rect") { @@ -615,7 +586,6 @@ export class SvgToPdfPage { } if (element.textContent.startsWith("$map(")) { mapTextSpecs.push(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(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 (t).textFor(language) + rest + } else { + console.error("Could not get textFor from ", t, "for path", text) + } + } + } export class SvgToPdf { - public static readonly templates : Record= { - 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 = { + 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 ?? {} 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 { + 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 { for (const page of this._pages) { await page.Prepare() } return this } + + public async PrepareLanguages(languages: string[]): Promise { + 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 + } } diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 5348e48b1..a95f20575 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -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; } diff --git a/index.css b/index.css index 72970a61d..6a2fb0a3f 100644 --- a/index.css +++ b/index.css @@ -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; }