Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental language translation #3198

Draft
wants to merge 69 commits into
base: master
Choose a base branch
from

Conversation

G-Ambatte
Copy link
Collaborator

This PR resolves #2624.

This PR adds a translate function to the String prototype, which when called searches a given YAML file using the string as key, returning the YAML file's value if found, and the original text if not.

@G-Ambatte G-Ambatte self-assigned this Dec 21, 2023
@G-Ambatte G-Ambatte added the P2 - minor feature or not urgent Minor bugs or less-popular features label Dec 21, 2023
@G-Ambatte
Copy link
Collaborator Author

The current state of this PR is that only the Account menu has been modified to use the new translate function.

@5e-Cleric
Copy link
Member

I could put this syntax into all texts, should i or do you think we should wait in case we want another syntax?

@G-Ambatte
Copy link
Collaborator Author

I could put this syntax into all texts, should i or do you think we should wait in case we want another syntax?

I've kept it as a Draft PR for now, specifically so we could let the syntax discussion play out.
That said, it's been a couple of days, and I think the following ideas are worth following up on:

  1. The template file key should match the literal string being translated.
  2. A translateDefaults function, so that calls can be .translate() instead of .translate(translateOpts).
  3. React's useContext hook. It seems to be ideal for this feature, but may require some refactoring to make it work.
  4. Use AccountPage to hold the UI elements for language selection.

On 1. As it currently stands, if the string to be translated does not exist in the localization file, then the string itself is returned, allowing as to use the exact text as the key. That said, it doesn't HAVE to be the exact text, and where the text is particularly long, it's probably a good idea to use an Identifier instead.
However, I have not put any thought into a process for identifying strings that should be referenced by ID instead of literal text. Maybe string.length > 20 or some other arbitrary number?
This will effect the localization template, so I'd like to lock it down before we push too much further into the process; there may be work already completed that will be affected by this decision.

On 2. This seems like a good idea - as each JSX file deals with one area of the UI, we should be able to set the defaults once per file and have it "just work" from then on. Implementation, however, is usually where things fall apart.

For 3. I think we need useContext in order to switch the state of the entire UI, and that appears to be the exact purpose of the feature. That said, I have only a passing familiarity with useContext, so I need to go back and look at how to actually implement such a thing.

Finally, 4. Ultimately, no matter what page this is implemented on, a value needs to be manipulated and stored by the page, then retrieved and applied to the data source that the translate() function uses.
a. AccountPage: build the UI to manipulate the value
b. AccountPage: store the value
c. Translate(): retrieve the value
d. Translate(): apply the appropriate localization data

@G-Ambatte
Copy link
Collaborator Author

I intend to take a further stab at some of these as time allows in the next couple of days; however, given the time of year, time is a fleeting thing right now...

@ericscheid
Copy link
Collaborator

  1. The template file key should match the literal string being translated.
  2. A translateDefaults function, so that calls can be .translate() instead of .translate(translateOpts).

On 1. As it currently stands, if the string to be translated does not exist in the localization file, then the string itself is returned, allowing as to use the exact text as the key. That said, it doesn't HAVE to be the exact text, and where the text is particularly long, it's probably a good idea to use an Identifier instead.

It is entirely reasonable to have the source string in the code be "Error #234" and the translation be "An error occured (#234). This sometimes happens because [long reasons]. You can try [stuff]". The "identifier", per se, doesn't have to be a neologism, it can be a simple plain language string (one we'd be happy getting stuck as being permanent as a key only visible in the code .. so no rude words pls).

If we do go with using strings-in-the-code as the keys-in-the-translations, we definitely should have a code-to-English translation file (and not rely on the fallback-to-key method for English UI). Otherwise we can't easily change the English UI (because it would break the keys to other langs).

Shouldn't be hard to find a volunteer to write up the English translation file ;-)

@ericscheid
Copy link
Collaborator

Quick check .. does this PR include setting the <html lang="%UI-LANG%"> attribute?

As of v3.10, THB does not assert a lang on <html>. It does have Open Graph tags, of which the spec defaults to en_US for og:locale .. though that should be set from the brew language, not the UI language, so that's a separate PR.

@5e-Cleric
Copy link
Member

does this PR include setting the attribute?

We could, not sure it is necessary, but it is best practice

@5e-Cleric
Copy link
Member

5e-Cleric commented Dec 30, 2023

Last time we talked about this, we talked about what languages we could add as a start, we agreed on:

  • EN (is there value in doing EN-US and EN-GB?)
  • DE (German)
  • ES (Spanish)
  • PT-BR (Brazilian Portuguese
  • CA (Catalan)
  • HV (High Valyrian) @G-Ambatte we are doing it, right?

@G-Ambatte
Copy link
Collaborator Author

* HV (High Valyrian) @G-Ambatte we are doing it, right?

Engos is tongue, but if I remember correctly can also be used to mean "language"; while arilla is "beer" but might be close enough to "brews".

This is why we need to finish a template file, to determine exactly what words we actually need.

@5e-Cleric
Copy link
Member

This is why we need to finish a template file, to determine exactly what words we actually nee

base-localization-file.zip
You mean this one still lacks strings?

locale/locale-es-ES.yaml Outdated Show resolved Hide resolved
@Gazook89
Copy link
Collaborator

Gazook89 commented Feb 2, 2024

I think in the spots where we use MomentJS to set relative times ("1 year ago"), such as in the metadata viewer in the navbar and in the Recent Items dropdown, we can use translation as well. Check out this page from MomentJS docs: http://momentjs.com/docs/#/i18n/instance-locale/

Comment on lines +175 to +178
{this.renderSortOption('title'.translate(), 'alpha')}
{this.renderSortOption('created date'.translate(), 'created')}
{this.renderSortOption('updated date'.translate(), 'updated')}
{this.renderSortOption('views'.translate(), 'views')}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of translating each of these four options individually, you can instead place a .translate() on line ~125:

	renderSortOption : function(sortTitle, sortValue){
		return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
			<button
				value={`${sortValue}`}
				onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
			>
				{`${sortTitle.translate()}`}   //  <---------  add .translate() here
			</button>
			{this.state.sortType == sortValue &&
				<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
			}
		  </div>;
	},

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am unfamiliar with this specific code, would we keep the same key names in the yaml files?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the yaml's will stay the same.

Right now in the PR, the sort buttons are rendered in the renderSortOption function, and the actual text that displays on the buttons is done here:

<button ...>
	{`${sortTitle}`}
</button>

sortTitle is an arg of renderSortOption, and is just a string. In the PR, right now, the string that is being passed here into this arg is already translated. But it doesn't have to be. Instead, you can pass the original english string into renderSortOption. Then, we can translate sortTitle (which is just the string we are passing in).

Just for completeness, here are the two entire functions as I describe them:

	renderSortOption : function(sortTitle, sortValue){
		return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
			<button
				value={`${sortValue}`}
				onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
			>
				{`${sortTitle.translate()}`}
			</button>
			{this.state.sortType == sortValue &&
				<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
			}
		  </div>;
	},

// ...

	renderSortOptions : function(){
		''.setTranslationDefaults(['userPage', 'filters']);
		return <div className='sort-container'>
			<h6>{'sortBy'.translate()}:</h6>
			{this.renderSortOption('title', 'alpha')}
			{this.renderSortOption('created date', 'created')}
			{this.renderSortOption('updated date', 'updated')}
			{this.renderSortOption('views', 'views')}
			{/* {this.renderSortOption('Latest', 'latest')} */}

			{this.renderFilterOption()}
		</div>;
	},

(note, both of these have very similar function names....possibly that should be addressed but I'm not concerned about it now)

Copy link
Collaborator

@Gazook89 Gazook89 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments in this review, and then a couple of separate comments because I didn't think i'd do as many as I did.

Also-- Do you have any ideas on how tooltips can be translated? It seems right now that tooltips are done entirely with LESS/CSS, going all the way to tooltip.less. Can translate() work in a LESS file?

</div>;
return _.map(brewCollection, (brewGroup, idx)=>{
return <div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'}</h1>
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'.translate()}</h1>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, this does not translate "Gazook89's Published Brews" / "Gazook89's Unpublished Brews". This could be accomplished with a change on this line, and on the userPage.jsx. This will change the title of the brewCollections, so someone wiser than I (@calculuschild , @G-Ambatte for example of a non-exhaustive list) might want to confirm this doesn't pose other dangers.

But the gist of it is:

listPage line 217

				<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{`${brewGroup.title} ` + `${brewGroup.class} brews`.translate(['userPage']) || 'No Title'.translate(['userPage'])}</h1>

userPage line 34

		const brewCollection = [
			{
				title : `${usernameWithS}`,
				class : 'published',
				brews : brews.published
			}
		];

result:
image

Probably there is some better way of concatenating the string together for the translate function, but right now I couldn't think of one.

Copy link
Collaborator

@ericscheid ericscheid Feb 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, this does not translate "Gazook89's Published Brews" / "Gazook89's Unpublished Brews".

[...] Probably there is some better way of concatenating the string together for the translate function, but right now I couldn't think of one.

I suggest the translate() function include an optional parameter of key:values to be merged into the translated string. such that the brewCollection title could be translated via

"username-published-brews".translate({"name": "Gazook89"})

The english translation reference YAML file could then have for the username-published-brews key this translated value:

${name}'s published brews

or even this, to properly handle English possessive formation for names ending with "s":

${name}${name.endsWith('s') || name.endsWith('S') ? '’' : '’s'} published brews

This can handle simple substitution (e.g. inserting a name into the string), weird substitutions (e.g. irregular possessive s formation), multiple values (e.g. errorNumber, date, reference merged into a longer paragraph), and even basic math etc (e.g. ${number * factor} or string operations (e.g. "le" or "la", according to circumstance).

And, of course, handle straight up static strings (e.g."Hello!": "Bon jour!")


In the translate() function itself, it would dynamically define an anon function that returns the filled-template string (via the with (merge) statement to get the merge values into the scope).

String.prototype.translate = function (values) {
// exempli gratia..
// also ignoring the `groups` parameter functionality requirement
  const translationData = {
    "Names Published Brews": "${name}${name.endsWith('s') || name.endsWith('S') ? '’' : '’s'} Brassins Publiés",
    "Names Unpublished Brews": "${name}${name.endsWith('s') || name.endsWith('S') ? '’' : '’s'} Brassins Non Publiés",
    "Some string": "Jibber jabba",
    // Add more translations as needed
  };

  const key = Object.keys(values)[0];

  if (translationData[key]) {
    // Dynamically define the function
    const functionString = `with (values) return \`${translationData[key]}\`;`;
    const translationFunction = new Function('name', functionString);

    // Call the dynamically defined function
    return translationFunction(values);
  } else {
    return `Translation not found for key: ${key}`;
  }
};
// CODE NOT TESTED

NB. this would require a refactor of code to date, since the current translate() function already has one optional parameter which is an unlabelled array of strings for scoping the translation lookup group). I suggest instead

"source-string".translate( { values: {"key": "value", "another-key": "another value"}, groups: ["array", "of", "strings"] })

(Writing a function with an optional unlabelled parameter value is a code smell. Pass that thing inside an object with a key, please).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OR, and just hear me out, simply change the way the userpage displays the titles, to have the username on top of the page, and then simply:

Published brews

Unpublished brews

and voilà, we just saved having to build that function at all.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still needs discussion.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collection titles is not the only place where we want to present a translated message that contains various values merged into it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with both: we can easily sidestep the issue on these particular headers without losing anything, but we may still benefit from the functionality that eric proposes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Eric's proposal. Languages are not always going to follow the same grammatical sequence, so we need a way for each language to place passed-in values in the correct point in the text.

client/homebrew/pages/basePages/listPage/listPage.jsx Outdated Show resolved Hide resolved
@5e-Cleric
Copy link
Member

I think i have fixed most comments and reviews, except for those which require discussion or i need an answer about something, if i'm wrong, please notify me about it.

@5e-Cleric
Copy link
Member

5e-Cleric commented Feb 5, 2024

A few comments in this review, and then a couple of separate comments because I didn't think i'd do as many as I did.

Also-- Do you have any ideas on how tooltips can be translated? It seems right now that tooltips are done entirely with LESS/CSS, going all the way to tooltip.less. Can translate() work in a LESS file?

I just figured how it works, yeah, we might have a problem there. Looking at it.

Comment on lines +436 to +442
const languageOptions = ['pt-BR','en-US', 'es-ES', 'fr-FR'];
const langCodeCorrect =()=> {
if (languageOptions.indexOf(req.language) > -1) {return req.language;}
return 'en-US';
};

const langPreference = langCodeCorrect();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably just simplify this to

Suggested change
const languageOptions = ['pt-BR','en-US', 'es-ES', 'fr-FR'];
const langCodeCorrect =()=> {
if (languageOptions.indexOf(req.language) > -1) {return req.language;}
return 'en-US';
};
const langPreference = langCodeCorrect();
const langPreference = languageOptions.includes(req.language) ? req.language : 'en-US';

Comment on lines +5 to +20
String.prototype.translate = function(_groups = String.prototype.getTranslationDefaults()) {
const text = this.valueOf();
const groups = _groups ? Array.from(_groups) : [];
let obj = localeData;
while (groups?.length > 0) {
if(obj[groups[0]]) {
obj = obj[groups[0]];
groups.shift();
continue;
}
break;
}

if(obj[text] && obj[text].length > 0) return `${obj[text]}`;
return localeData[text]?.length > 0 ? `${localeData[text]}` : `${text}`;
};
Copy link
Member

@calculuschild calculuschild Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest we add a parameter interpolations to the translate function which is an object containing key-value pairs of any text that need to be interpolated in the text.

'UsersPublishedBrews'.translate({username : this.props.username});

Then in the YAML file, we can just add some kind of marker (I would vote the standard ${}):

UsersPublishedBrews : '${username}'s published brews'
...
UsersPublishedBrews : 'Brews publicados de ${username}'

Suggested change
String.prototype.translate = function(_groups = String.prototype.getTranslationDefaults()) {
const text = this.valueOf();
const groups = _groups ? Array.from(_groups) : [];
let obj = localeData;
while (groups?.length > 0) {
if(obj[groups[0]]) {
obj = obj[groups[0]];
groups.shift();
continue;
}
break;
}
if(obj[text] && obj[text].length > 0) return `${obj[text]}`;
return localeData[text]?.length > 0 ? `${localeData[text]}` : `${text}`;
};
String.prototype.translate = function(_groups = String.prototype.getTranslationDefaults(), interpolations = {}) {
const text = this.valueOf();
const groups = _groups ? Array.from(_groups) : [];
let obj = localeData;
while (groups?.length > 0) {
if(obj[groups[0]]) {
obj = obj[groups[0]];
groups.shift();
continue;
}
break;
}
let translatedText = obj[text] && obj[text].length > 0 ? `${obj[text]}` : localeData[text]?.length > 0 ? `${localeData[text]}` : `${text}`;
// Interpolate the values
for (let key in interpolations) {
translatedText = translatedText.replace(new RegExp(`\${${key}}`, 'g'), interpolations[key]);
}
return translatedText;
};

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then in the YAML file, we can just add some kind of marker (I would vote the standard ${}):

Why partially emulate template strings when you could implement actual template strings?

 if (translationData[key]) {
    // Dynamically define the function
    const functionString = `with (values) return \`${translationData[key]}\`;`;
    const translationFunction = new Function('name', functionString);

    // Call the dynamically defined function
    return translationFunction(values);
  } else {
    return `Translation not found for key: ${key}`;
  }

Copy link
Member

@calculuschild calculuschild Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not opposed to that. But for what is essentially a find/replace action, I'm not clear what the added benefit would be given the increased complexity. (The proposed code also uses deprecated and highly discouraged syntax, so we should avoid that if we want something like this.)

Copy link
Collaborator

@ericscheid ericscheid Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposal supports `${expression}`, which is more than simple substitution, and thus supports `${name.endsWith('s') ? name : name + 's'} Published Brews` (for example).

So .. odd grammar rules (like English possessive 's), or pluralisation rules, or even simply date formatting .. can all be supported via `${expression}` but not ${field}.

The general concept of dynamically compiling a function from a string is usually discouraged because you really don't want to allow user-supplied strings being compiled. In this situation though only the strings in the translation files get compiled, and they should be getting reviewed when committed.

The other issue sometimes noted is that using new Function constructors can impair code optimisation and is less performant. Unless we want to hardcode a bunch of functions for use by translate(), we're gonna have to compile on the fly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The points about new Function are valid and something to be careful with. But my main concern is actually the with operator, which is deprecated and should not be used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might suggest lodash's _.template function which can evaluate a string with expressions like this.

https://lodash.com/docs/#template

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah .. my understanding with the with operator is that the main discouragment has to do with potentially causing unintended variable declarations and making it difficult to determine where variables are defined. Last I heard it wasn't actually deprecated, per se.

Nonetheless, I support the recommendation of _.template .. it has additional checks too (but still uses new Function as the fundamental technique, heh).

Copy link
Member

@calculuschild calculuschild Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So for reference, I believe the _.template could be used like this (last 3 lines):

String.prototype.translate = function(_groups = String.prototype.getTranslationDefaults(), interpolations = {}) {
    const text = this.valueOf();
    const groups = _groups ? Array.from(_groups) : [];
    let obj = localeData;
    while (groups?.length > 0) {
        if(obj[groups[0]]) {
            obj = obj[groups[0]];
            groups.shift();
            continue;
        }
        break;
    }
    let translatedText = obj[text] && obj[text].length > 0 ? `${obj[text]}` : localeData[text]?.length > 0 ? `${localeData[text]}` : `${text}`;

    // Interpolate the values
    var compiledTemplate = _.template(translatedText);
    translatedText = compiledTemplate(interpolations);
    return translatedText;
};

};

// Add defaults
String.prototype.getTranslationDefaults = function() {
Copy link
Member

@calculuschild calculuschild Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will just comment that I am not a huge fan of this groups parameter. It seems to add a layer of complexity and boilerplate code to every file:

const translateOpts = ['nav', 'saveDropdown'];

''.setTranslationDefaults(translateOpts);

Can I suggest we instead just handle the different translation groups via group.key notation?

'nav.save'.translate()

Might be a little repetitive, but should also be more legible and less chance of conflict for the pages where we are translating from multiple groups anyway.

@calculuschild calculuschild added the 🔍 R3 - Reviewed - Awaiting Fixes 🔧 PR is okayed but needs fixes before merging label Apr 9, 2024
@dbolack-ab
Copy link
Collaborator

Has this been shelved for another path?

@5e-Cleric 5e-Cleric added 🔍 R2 - Reviewed - Needs Help 🫱 PR is okayed but is stuck on an obstacle and removed 🔍 R3 - Reviewed - Awaiting Fixes 🔧 PR is okayed but needs fixes before merging labels Oct 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
P2 - minor feature or not urgent Minor bugs or less-popular features 🔍 R2 - Reviewed - Needs Help 🫱 PR is okayed but is stuck on an obstacle UI/UX User Interface, user experience
Projects
Status: Backlog
Development

Successfully merging this pull request may close these issues.

Modify Homebrewery strings to allow alternate languages
6 participants