From 56baa4718f1c07886092b76c59c713a34cd5905f Mon Sep 17 00:00:00 2001 From: Sam Wilson Date: Fri, 21 Dec 2018 15:55:39 -0800 Subject: [PATCH] Add auto-preview of image as labels are changed This adds functionality to be able to live-preview translations as they're changed in the form. To do this, it updates the API to be able to first receive (via POST) a new set of translations, which are then added to the SVG file and a PNG rendered out to the filesystem. Then, when a 2nd request (GET this time) is received for that rendered PNG file, it's returned. This is done by creating a temporary unique identifier for the file (based on the submitted translations). The front-end is modified to make this first request, and when it receives the response all it has to do is update the image's src attribute and the 2nd request happens. The process of adding these strings back into SvgFile was a bit cumbersome, so a new method SvgFile::setTranslations() was added, which handles adding the new text and tspan node info. Tests were updated, but the broken controller test remains for the time being. Bug: https://phabricator.wikimedia.org/T207203 --- assets/translate.js | 58 +++++++++- i18n/en.json | 1 + i18n/qqq.json | 1 + .../{app.bd58f67a.js => app.b885261f.js} | 2 +- public/assets/entrypoints.json | 2 +- public/assets/i18n/app/en.json | 1 + public/assets/i18n/app/qqq.json | 1 + public/assets/manifest.json | 2 +- src/Controller/ApiController.php | 105 +++++++++++------- src/Controller/TranslateController.php | 13 ++- src/Model/Svg/SvgFile.php | 32 ++++++ src/Service/FileCache.php | 14 +-- templates/base.html.twig | 1 + templates/translate.html.twig | 4 +- tests/Controller/ApiControllerTest.php | 5 +- tests/Model/Svg/SvgFileTest.php | 7 ++ 16 files changed, 181 insertions(+), 68 deletions(-) rename public/assets/{app.bd58f67a.js => app.b885261f.js} (99%) diff --git a/assets/translate.js b/assets/translate.js index 11296942..66140f07 100644 --- a/assets/translate.js +++ b/assets/translate.js @@ -9,22 +9,29 @@ $( function () { return; } function onSelectTargetLang( language ) { - // Save the language name and code in the widget. + var $imgElement, newImageUrl; + // 1. Save the language name and code in the widget. this.setLabel( $.uls.data.languages[ language ][ 2 ] ); this.setData( language ); - // Also switch what's displayed in the form when a new language is selected in the ULS. + this.setValue( language ); + // 2. Switch what's displayed in the form when a new language is selected in the ULS. $( '.translation-fields .oo-ui-fieldLayout' ).each( function () { - var field = OO.ui.infuse( $( this ) ).getField(); - if ( appConfig.translations[ field.data.nodeId ] && - appConfig.translations[ field.data.nodeId ][ language ] + var field = OO.ui.infuse( $( this ) ).getField(), + tspanId = field.data[ 'tspan-id' ]; + if ( appConfig.translations[ tspanId ] && + appConfig.translations[ tspanId ][ language ] ) { // If there's a translation available, set the field's value. - field.setValue( appConfig.translations[ field.data.nodeId ][ language ].text ); + field.setValue( appConfig.translations[ tspanId ][ language ].text ); } else { // Otherwise, blank the field. field.setValue( '' ); } } ); + // 3. Update the image. + $imgElement = $( '.image img' ); + newImageUrl = $imgElement.attr( 'src' ).replace( /[a-z_-]*\.png.*$/, language + '.png' ); + $imgElement.attr( 'src', newImageUrl ); } targetLangButton = OO.ui.infuse( $targetLangButton ); targetLangButton.$element.uls( { @@ -68,3 +75,42 @@ $( function () { } ); } ); } ); + +/** + * When a translation field is changed, update the image preview. + */ +$( function () { + $( '.translation-fields .oo-ui-fieldLayout .oo-ui-inputWidget' ).each( function () { + var inputWiget = OO.ui.infuse( $( this ) ), + $imgElement = $( '.image img' ), + targetLangWidget = OO.ui.infuse( $( '.target-lang-widget' ) ), + targetLangCode = targetLangWidget.getValue(), + requestParams = {}, + updatePreviewImage = function () { + // Go through all fields and construct the request parameters. + $( '.translation-fields .oo-ui-fieldLayout' ).each( function () { + var fieldLayout = OO.ui.infuse( $( this ) ), + tspanId = fieldLayout.getField().data[ 'tspan-id' ]; + requestParams[ tspanId ] = fieldLayout.getField().getValue(); + } ); + // Update the image. + $.ajax( { + type: 'POST', + url: appConfig.baseUrl + 'api/translate/' + $imgElement.data( 'filename' ) + '/' + targetLangCode, + data: requestParams, + success: function ( result ) { + $imgElement.attr( 'src', result.imageSrc ); + }, + error: function () { + OO.ui.alert( $.i18n( 'preview-error-occurred' ) ); + } + } ); + }; + // Update the preview image on field blur and after two seconds of no typing. + inputWiget.$input.on( 'blur', updatePreviewImage ); + inputWiget.on( 'change', OO.ui.debounce( updatePreviewImage, 2000 ) ); + } ); + // Trigger a change, to force a refresh of the preview on page load (to catch browser-cached + // input values). + $( '.translation-fields .oo-ui-fieldLayout .oo-ui-inputWidget input' ).trigger( 'blur' ); +} ); diff --git a/i18n/en.json b/i18n/en.json index 00a13333..89f5a266 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -33,6 +33,7 @@ "download-button-label": "Download", "download-or-upload": "or", "translation-image-alt": "The image that's currently being translated. No description available.", + "preview-error-occurred": "An error occurred when fetching the preview. Please continue translating, but if this error persists do report a bug (via the link in the footer below).", "download-icon-alt": "An icon indicating file-download", "pick-an-image-title": "Pick an image", diff --git a/i18n/qqq.json b/i18n/qqq.json index 95fccc99..f2199a43 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -33,6 +33,7 @@ "download-button-label": "Label for the button that downloads the translated SVG to the user's local computer.", "download-or-upload": "Label positioned between the download and the upload buttons.", "translation-image-alt": "Alt text for the image that's currently being translated. Doesn't explain the image, just explains that we don't explain it.", + "preview-error-occurred": "Error message displayed in a popup when fetching a preview failed. The only option is 'OK' to close the popup.", "download-icon-alt": "Alt text for the download icon.", "pick-an-image-title": "The title of the 'pick an image' step of the tutorial box.", diff --git a/public/assets/app.bd58f67a.js b/public/assets/app.b885261f.js similarity index 99% rename from public/assets/app.bd58f67a.js rename to public/assets/app.b885261f.js index 3f9ff770..b66d8b4e 100644 --- a/public/assets/app.bd58f67a.js +++ b/public/assets/app.b885261f.js @@ -62,7 +62,7 @@ function(t){var e,i,n,o,s,a,r,l,u,c,h,d,p,g,f,m,y,b,v,w="sizzle"+1*new Date,x=t. * @licence GNU General Public Licence 2.0 or later * @licence MIT License */ -!function(t){"use strict";var e,i,n,o=Array.prototype.slice;(e=function(i){this.options=t.extend({},e.defaults,i),this.parser=this.options.parser,this.locale=this.options.locale,this.messageStore=this.options.messageStore,this.languages={}}).prototype={localize:function(e){var i,n,o,s,a,r;for(o=this.locale,s=0;o;){n=(i=o.split("-")).length;do{if(a=i.slice(0,n).join("-"),r=this.messageStore.get(a,e))return r;n--}while(n);if("en"===o)break;o=t.i18n.fallbacks[this.locale]&&t.i18n.fallbacks[this.locale][s]||this.options.fallbackLocale,t.i18n.log("Trying fallback locale for "+this.locale+": "+o+" ("+e+")"),s++}return""},destroy:function(){t.removeData(document,"i18n")},load:function(e,i){var n,o,s,a={};if(e||i||(e="i18n/"+t.i18n().locale+".json",i=t.i18n().locale),"string"==typeof e&&"json"!==e.split("?")[0].split(".").pop()){for(a[i]=e+"/"+i+".json",n=(t.i18n.fallbacks[i]||[]).concat(this.options.fallbackLocale),o=0;o-1)return!0;for(n=0;n1},setGroupByRegionOverride:function(t){this.groupByRegionOverride=t},render:function(){var t,i=this.buildQuicklist(),n=[],o={all:"All languages",WW:"Worldwide",SP:"Special",AM:"America",EU:"Europe",ME:"Middle East",AS:"Asia",AF:"Africa",PA:"Pacific"};i.length?n.push(i):this.$element.addClass("uls-lcd--no-quicklist"),this.options.showRegions.forEach(function(i){this.regionLanguages[i]=[],t=e("
").addClass("uls-lcd-region-section hide").attr("data-region",i),e("

").attr("data-i18n","uls-region-"+i).addClass("uls-lcd-region-title").text(o[i]).appendTo(t),n.push(t)}.bind(this)),this.$element.append(n),this.i18n()},renderRegions:function(){var t,i=this;this.$element.removeClass("uls-no-results"),this.$element.children(".uls-lcd-region-section").each(function(){var n=e(this),o=n.data("region");n.is(".uls-lcd-quicklist")||(n.children(".uls-language-block").remove(),(t=i.regionLanguages[o])&&0!==t.length?(i.renderRegion(n,t,i.options.itemsPerColumn,i.options.columns),n.removeClass("hide"),i.regionLanguages[o]=[]):n.addClass("hide"))})},renderRegion:function(t,i,n,o){var s,a,r,l,u,c,h=i.length,d=[],p=[],g=[];if(i=e.uls.data.sortByScriptGroup(i.sort(e.uls.data.sortByAutonym)),s=1===o?"twelve columns":2===o?"six columns":"three columns",1===this.options.columns){for(a=0;a").addClass(s).append(d)),g.push(e("
").addClass("row uls-language-block").append(p))}else for(a=0;a1&&(c=!0):l=e.uls.data.getScriptGroupOfLanguage(i[a]),l=u,d.push(this.renderItem(i[a])),(d.length>=n||r||c)&&(p.push(e("
    ").addClass(s).append(d)),d=[],(p.length>=o||r)&&(g.push(e("
    ").addClass("row uls-language-block").append(p)),p=[]));t.append(g)},renderItem:function(t){var i,n,o,s;return n=this.options.languages[t],o=e.uls.data.getAutonym(t)||n||t,(s=document.createElement("li")).title=n,s.setAttribute("data-code",t),(i=document.createElement("a")).appendChild(document.createTextNode(o)),i.className="autonym",i.lang=t,i.dir=e.uls.data.getDir(t),s.appendChild(i),this.options.languageDecorator&&this.options.languageDecorator(e(i),t),s},i18n:function(){this.$element.find("[data-i18n]").i18n()},quicklist:function(){this.$element.find(".uls-lcd-quicklist").removeClass("hide")},buildQuicklist:function(){var t,i,n;return null!==this.cachedQuicklist?this.cachedQuicklist:(e.isFunction(this.options.quickList)&&(this.options.quickList=this.options.quickList()),this.options.quickList.length?((t=(t=this.options.quickList).slice(0,16)).sort(e.uls.data.sortByAutonym),i=e("
    ").addClass("uls-lcd-region-section uls-lcd-quicklist"),n=e("

    ").attr("data-i18n","uls-common-languages").addClass("uls-lcd-region-title").text("Suggested languages"),i.append(n),this.renderRegion(i,t,this.options.itemsPerColumn,this.options.columns),n.i18n(),this.cachedQuicklist=i,this.cachedQuicklist):(this.cachedQuicklist=e([]),this.cachedQuicklist))},show:function(){this.regionDivs||this.render()},empty:function(){this.$element.addClass("uls-lcd--no-quicklist"),this.$element.find(".uls-lcd-quicklist").addClass("hide")},focus:function(){this.$element.focus()},noResults:function(e,i){var n;if(this.$element.addClass("uls-no-results"),this.$element.find(".uls-no-results-view").remove(),"function"==typeof this.options.noResultsTemplate)n=this.options.noResultsTemplate.call(this,i.query);else{if(!(this.options.noResultsTemplate instanceof t))throw new Error("noResultsTemplate option must be either jQuery or function returning jQuery");n=this.options.noResultsTemplate}this.$element.append(n.addClass("uls-no-results-view").i18n())},listen:function(){var t=this;this.options.clickhandler&&this.$element.on("click",".row li",function(i){t.options.clickhandler.call(this,e(this).data("code"),i)})}},e.fn.lcd=function(t){return this.each(function(){var n=e(this),o=n.data("lcd"),s="object"==typeof t&&t;o||n.data("lcd",o=new i(this,s)),"string"==typeof t&&o[t]()})},e.fn.lcd.defaults={languages:[],showRegions:["WW","AM","EU","ME","AF","AS","PA"],groupByRegion:"auto",itemsPerColumn:8,columns:4,languageDecorator:void 0,quickList:[],clickhandler:void 0,noResultsTemplate:function(){var t,i=e('
    \t\t

    No results found

    \t\t
    \t\t
    \t\t
    You can search by language name, script name, ISO code of language or you can browse by region.
    \t\t
    ');return(t=this.buildQuicklist().clone()).removeClass("hide").find("h3").data("i18n","uls-no-results-suggestion-title").text("You may be interested in:").i18n(),i.find(".uls-no-results-suggestions").append(t),i}}}(t)}).call(this,i("EVdn"))},ML86:function(t,e,i){},Mh5E:function(t,e,i){(function(t){var e;!function(){return function t(i,n,o){function s(r,l){if(!n[r]){if(!i[r]){if(!l&&"function"==typeof e&&e)return e(r,!0);if(a)return a(r,!0);var u=new Error("Cannot find module '"+r+"'");throw u.code="MODULE_NOT_FOUND",u}var c=n[r]={exports:{}};i[r][0].call(c.exports,function(t){return s(i[r][1][t]||t)},c,c.exports,t,i,n,o)}return n[r].exports}for(var a="function"==typeof e&&e,r=0;r-1)return!0;for(n=0;n1},setGroupByRegionOverride:function(t){this.groupByRegionOverride=t},render:function(){var t,i=this.buildQuicklist(),n=[],o={all:"All languages",WW:"Worldwide",SP:"Special",AM:"America",EU:"Europe",ME:"Middle East",AS:"Asia",AF:"Africa",PA:"Pacific"};i.length?n.push(i):this.$element.addClass("uls-lcd--no-quicklist"),this.options.showRegions.forEach(function(i){this.regionLanguages[i]=[],t=e("
    ").addClass("uls-lcd-region-section hide").attr("data-region",i),e("

    ").attr("data-i18n","uls-region-"+i).addClass("uls-lcd-region-title").text(o[i]).appendTo(t),n.push(t)}.bind(this)),this.$element.append(n),this.i18n()},renderRegions:function(){var t,i=this;this.$element.removeClass("uls-no-results"),this.$element.children(".uls-lcd-region-section").each(function(){var n=e(this),o=n.data("region");n.is(".uls-lcd-quicklist")||(n.children(".uls-language-block").remove(),(t=i.regionLanguages[o])&&0!==t.length?(i.renderRegion(n,t,i.options.itemsPerColumn,i.options.columns),n.removeClass("hide"),i.regionLanguages[o]=[]):n.addClass("hide"))})},renderRegion:function(t,i,n,o){var s,a,r,l,u,c,h=i.length,d=[],p=[],g=[];if(i=e.uls.data.sortByScriptGroup(i.sort(e.uls.data.sortByAutonym)),s=1===o?"twelve columns":2===o?"six columns":"three columns",1===this.options.columns){for(a=0;a").addClass(s).append(d)),g.push(e("
    ").addClass("row uls-language-block").append(p))}else for(a=0;a1&&(c=!0):l=e.uls.data.getScriptGroupOfLanguage(i[a]),l=u,d.push(this.renderItem(i[a])),(d.length>=n||r||c)&&(p.push(e("
      ").addClass(s).append(d)),d=[],(p.length>=o||r)&&(g.push(e("
      ").addClass("row uls-language-block").append(p)),p=[]));t.append(g)},renderItem:function(t){var i,n,o,s;return n=this.options.languages[t],o=e.uls.data.getAutonym(t)||n||t,(s=document.createElement("li")).title=n,s.setAttribute("data-code",t),(i=document.createElement("a")).appendChild(document.createTextNode(o)),i.className="autonym",i.lang=t,i.dir=e.uls.data.getDir(t),s.appendChild(i),this.options.languageDecorator&&this.options.languageDecorator(e(i),t),s},i18n:function(){this.$element.find("[data-i18n]").i18n()},quicklist:function(){this.$element.find(".uls-lcd-quicklist").removeClass("hide")},buildQuicklist:function(){var t,i,n;return null!==this.cachedQuicklist?this.cachedQuicklist:(e.isFunction(this.options.quickList)&&(this.options.quickList=this.options.quickList()),this.options.quickList.length?((t=(t=this.options.quickList).slice(0,16)).sort(e.uls.data.sortByAutonym),i=e("
      ").addClass("uls-lcd-region-section uls-lcd-quicklist"),n=e("

      ").attr("data-i18n","uls-common-languages").addClass("uls-lcd-region-title").text("Suggested languages"),i.append(n),this.renderRegion(i,t,this.options.itemsPerColumn,this.options.columns),n.i18n(),this.cachedQuicklist=i,this.cachedQuicklist):(this.cachedQuicklist=e([]),this.cachedQuicklist))},show:function(){this.regionDivs||this.render()},empty:function(){this.$element.addClass("uls-lcd--no-quicklist"),this.$element.find(".uls-lcd-quicklist").addClass("hide")},focus:function(){this.$element.focus()},noResults:function(e,i){var n;if(this.$element.addClass("uls-no-results"),this.$element.find(".uls-no-results-view").remove(),"function"==typeof this.options.noResultsTemplate)n=this.options.noResultsTemplate.call(this,i.query);else{if(!(this.options.noResultsTemplate instanceof t))throw new Error("noResultsTemplate option must be either jQuery or function returning jQuery");n=this.options.noResultsTemplate}this.$element.append(n.addClass("uls-no-results-view").i18n())},listen:function(){var t=this;this.options.clickhandler&&this.$element.on("click",".row li",function(i){t.options.clickhandler.call(this,e(this).data("code"),i)})}},e.fn.lcd=function(t){return this.each(function(){var n=e(this),o=n.data("lcd"),s="object"==typeof t&&t;o||n.data("lcd",o=new i(this,s)),"string"==typeof t&&o[t]()})},e.fn.lcd.defaults={languages:[],showRegions:["WW","AM","EU","ME","AF","AS","PA"],groupByRegion:"auto",itemsPerColumn:8,columns:4,languageDecorator:void 0,quickList:[],clickhandler:void 0,noResultsTemplate:function(){var t,i=e('
      \t\t

      No results found

      \t\t
      \t\t
      \t\t
      You can search by language name, script name, ISO code of language or you can browse by region.
      \t\t
      ');return(t=this.buildQuicklist().clone()).removeClass("hide").find("h3").data("i18n","uls-no-results-suggestion-title").text("You may be interested in:").i18n(),i.find(".uls-no-results-suggestions").append(t),i}}}(t)}).call(this,i("EVdn"))},ML86:function(t,e,i){},Mh5E:function(t,e,i){(function(t){var e;!function(){return function t(i,n,o){function s(r,l){if(!n[r]){if(!i[r]){if(!l&&"function"==typeof e&&e)return e(r,!0);if(a)return a(r,!0);var u=new Error("Cannot find module '"+r+"'");throw u.code="MODULE_NOT_FOUND",u}var c=n[r]={exports:{}};i[r][0].call(c.exports,function(t){return s(i[r][1][t]||t)},c,c.exports,t,i,n,o)}return n[r].exports}for(var a="function"==typeof e&&e,r=0;rserveContent($this->cache->getPath($filename), $lang); + $content = $this->svgRenderer->render($this->cache->getPath($filename), $lang); + return new Response($content, 200, [ + 'Content-Type' => 'image/png', + 'X-File-Hash' => sha1($content), + ]); } /** - * Serve a PNG rendering of the given SVG in the given language, based on the POSTed translations. - * @Route("/api/file/{filename}/{lang}.png", name="api_file_translated", methods="POST") - * @param string $filename - * @param Request $request - * @return Response + * Get a full filesystem path to a temporary PNG file. + * @param string $filename The base SVG filename. + * @param string $key They unique key to append to the filename. + * @return string */ - public function getFileWithTranslations(string $filename, string $lang, Request $request): Response + protected function getTempPngFilename(string $filename, string $key): string { - $filename = Title::normalize($filename); - $json = $request->getContent(); - if ('' === $json) { - return $this->getFile($filename, $lang); - } + return $this->cache->fullPath($filename.'_'.$key.'.png'); + } - $translations = \GuzzleHttp\json_decode($json, true); + /** + * Take POST data, write new translations into the SVG file, render a resultant PNG out to the + * filesystem, and return an identifier for that PNG file. + * + * @Route("/api/translate/{filename}/{lang}", name="api_file_request", methods="POST") + */ + public function requestFileWithTranslations(string $filename, string $lang, Request $request): Response + { + // Get the SVG file, and add in the new translations. + $filename = Title::normalize($filename); $path = $this->cache->getPath($filename); $file = new SvgFile($path); - $file->switchToTranslationSet($translations); - - // Write SVG file to filesystem, so it can be converted by rsvg. Use a unique filename - // because multiple users can be translating the same file at the same time (whereas - // getFile() above only ever serves the one version of a file). - $tmpSvgFilename = $this->cache->getTempSvgFile(); - $file->saveToPath($tmpSvgFilename); + $translations = $request->request->all(); + $file->setTranslations($lang, $translations); + + // Write the modified SVG out to the filesystem, named with a unique key. This is necessary + // both because multiple people could be translating the same file at the same time, and + // also means we can use the same key for the rendered PNG file. The key is generated from + // the translation set so that the + $fileKey = md5(serialize($translations)); + $tempPngFilename = $this->getTempPngFilename($filename, $fileKey); + $file->saveToPath($tempPngFilename); + + // Render the modified SVG to PNG, and return it's URL. + $renderedPngContents = $this->svgRenderer->render($tempPngFilename, $lang); + file_put_contents($tempPngFilename, $renderedPngContents); + $relativeUrl = $this->generateUrl('api_file_translated', [ + 'filename' => $filename, + 'key' => $fileKey, + 'lang' => $lang, + ]); + return new JsonResponse(['imageSrc' => $relativeUrl]); + } - return $this->serveContent($tmpSvgFilename, $lang); + /** + * Get a request for a already-rendered, custom-translated PNG (identified by a key), and + * return that file. + * + * @Route("/api/file/{filename}/{lang}/{key}.png", name="api_file_translated", methods="GET") + * + * @param string $filename + * @param Request $request + * @return Response + */ + public function getFileWithTranslations(string $filename, string $lang, string $key, Request $request): Response + { + $tempPngFilename = $this->getTempPngFilename($filename, $key); + if (file_exists($tempPngFilename)) { + return new BinaryFileResponse($tempPngFilename); + } + throw new NotFoundHttpException(); } /** @@ -103,20 +148,4 @@ public function getLanguages(string $fileName): Response return $this->json($langs); } - - /** - * Serves an SVG as a PNG. - * - * @param string $svgFilename Full path to the SVG file to convert and serve as a PNG. - * @param string $lang The language to use for the SVG's text. - * @return Response - */ - private function serveContent(string $svgFilename, string $lang): Response - { - $content = $this->svgRenderer->render($svgFilename, $lang); - return new Response($content, 200, [ - 'Content-Type' => 'image/png', - 'X-File-Hash' => sha1($content), - ]); - } } diff --git a/src/Controller/TranslateController.php b/src/Controller/TranslateController.php index d865b709..6d99c349 100644 --- a/src/Controller/TranslateController.php +++ b/src/Controller/TranslateController.php @@ -122,16 +122,19 @@ public function translate( // Messages. $translations = $svgFile->getInFileTranslations(); $formFields = []; - foreach ($translations as $nodeId => $langs) { + foreach ($translations as $tspanId => $translation) { + $fieldValue = isset($translation[$targetLang->getValue()]) + ? $translation[$targetLang->getValue()]['text'] + : ''; $inputWidget = new TextInputWidget([ - 'name' => 'translation-field-'.$nodeId, - 'value' => isset($langs[$targetLang->getValue()]) ? $langs[$targetLang->getValue()]['text'] : '', - 'data' => ['nodeId' => $nodeId], + 'name' => 'translation-field-'.$tspanId, + 'value' => $fieldValue, + 'data' => ['tspan-id' => $tspanId], ]); $field = new FieldLayout( $inputWidget, [ - 'label' => $langs[$sourceLang->getValue()]['text'], + 'label' => $translation[$sourceLang->getValue()]['text'], 'infusable' => true, ] ); diff --git a/src/Model/Svg/SvgFile.php b/src/Model/Svg/SvgFile.php index ddb81f6f..e9ab8fb8 100644 --- a/src/Model/Svg/SvgFile.php +++ b/src/Model/Svg/SvgFile.php @@ -430,6 +430,38 @@ public function getInFileTranslations(): array return $this->inFileTranslations; } + /** + * Set translations for a single language. + * @param string $lang Language code of the translations being provided. + * @param string[] $translations Array of tspan-IDs to message texts. + * @return string[][] + */ + public function setTranslations(string $lang, array $translations): array + { + // Load the existing translation structure, and go through it swapping the messages. + $inFileTranslations = $this->getInFileTranslations(); + $filteredTextNodes = $this->getFilteredTextNodes(); + foreach ($translations as $tspanId => $msg) { + // Set up the tspan node (including adding in the new message). + if (!isset($inFileTranslations[$tspanId][$lang])) { + $inFileTranslations[$tspanId][$lang] = $inFileTranslations[$tspanId]['fallback']; + $inFileTranslations[$tspanId][$lang]['id'] .= "-$lang"; + } + if (!empty($msg)) { + $inFileTranslations[$tspanId][$lang]['text'] = $msg; + } + // Set up the text node (if this is a new language). + $textId = $inFileTranslations[$tspanId][$lang]['data-parent']; + if (!isset($filteredTextNodes[$textId][$lang])) { + $filteredTextNodes[$textId][$lang] = $filteredTextNodes[$textId]['fallback']; + $filteredTextNodes[$textId][$lang]['id'] .= "-$lang"; + } + } + $allTranslations = array_merge($inFileTranslations, $filteredTextNodes); + // Add the updated translations back into the file. + return $this->switchToTranslationSet($allTranslations); + } + /** * Try to return $this->savedLanguages (a list of languages which have one or more * translations in-file). If it is not cached, analyse the SVG and hence generate it. diff --git a/src/Service/FileCache.php b/src/Service/FileCache.php index cfd91b49..9f33cc21 100644 --- a/src/Service/FileCache.php +++ b/src/Service/FileCache.php @@ -71,7 +71,7 @@ protected function tick(): void return; } - foreach (glob($this->fullPath('*.svg')) as $fileName) { + foreach (glob($this->fullPath('*.*')) as $fileName) { $this->statFile($fileName); } } @@ -103,18 +103,8 @@ protected function statFile(string $fileName): bool * @param string $fileName * @return string */ - protected function fullPath(string $fileName): string + public function fullPath(string $fileName): string { return $this->directory.DIRECTORY_SEPARATOR.$fileName; } - - /** - * Create a file with a unique .svg filename, with access permission set to 0600, - * and return its name. - * @return string - */ - public function getTempSvgFile(): string - { - return tempnam($this->directory, 'temp_').'.svg'; - } } diff --git a/templates/base.html.twig b/templates/base.html.twig index a6b3289f..04bd3983 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -75,6 +75,7 @@