Support language-specific pluralization (re: #597, #4964)

This commit is contained in:
Quincy Morgan
2020-09-14 17:21:00 -04:00
parent ab07064544
commit a1c2b7f73d
16 changed files with 179 additions and 122 deletions
+46 -39
View File
@@ -24,12 +24,6 @@ en:
changes_context: "({changes}) {base} {context}"
labeled_and_more: "{labeled} and {count} more"
modes:
add_feature:
title: Add a feature
description: "Search for features to add to the map."
key: Tab
result: "{count} result"
results: "{count} results"
add_area:
title: Area
description: "Add parks, buildings, lakes or other areas to the map."
@@ -83,22 +77,22 @@ en:
copy:
title: Copy
description:
single: Set this feature for pasting.
multiple: Set these features for pasting.
one: Set this feature for pasting.
other: Set these features for pasting.
annotation:
single: Copied a feature.
multiple: "Copied {n} features."
one: Copied a feature.
other: "Copied {n} features."
too_large:
single: This can't be copied because not enough of it is currently visible.
multiple: These can't be copied because not enough of them are currently visible.
one: This can't be copied because not enough of it is currently visible.
other: These can't be copied because not enough of them are currently visible.
paste:
title: Paste
description:
single: "Add a duplicate {feature} here."
multiple: "Add {n} duplicate features here."
one: "Add a duplicate {feature} here."
other: "Add {n} duplicate features here."
annotation:
single: Pasted a feature.
multiple: "Pasted {n} features."
one: Pasted a feature.
other: "Pasted {n} features."
nothing_copied: No features have been copied.
circularize:
title: Circularize
@@ -200,7 +194,9 @@ en:
line: Deleted a line.
area: Deleted an area.
relation: Deleted a relation.
multiple: "Deleted {n} features."
feature:
one: "Deleted a feature."
other: "Deleted {n} features."
too_large:
single: This feature can't be deleted because not enough of it is currently visible.
multiple: These features can't be deleted because not enough of them are currently visible.
@@ -227,12 +223,14 @@ en:
address: Remove all non-address tags.
annotation:
building:
single: Downgraded a feature to a basic building.
multiple: "Downgraded {n} features to basic buildings."
one: Downgraded a feature to a basic building.
other: "Downgraded {n} features to basic buildings."
address:
single: Downgraded a feature to an address.
multiple: "Downgraded {n} features to addresses."
multiple: "Downgraded {n} features."
one: Downgraded a feature to an address.
other: "Downgraded {n} features to addresses."
generic:
one: "Downgraded a feature."
other: "Downgraded {n} features."
has_wikidata_tag:
single: This feature can't be downgraded because it has a Wikidata tag.
multiple: These features can't be downgraded because some have Wikidata tags.
@@ -295,7 +293,9 @@ en:
title: Merge
description: Merge these features.
key: C
annotation: "Merged {n} features."
annotation:
one: "Merged a feature."
other: "Merged {n} features."
not_eligible: These features can't be merged.
not_adjacent: These features can't be merged because their endpoints aren't connected.
restriction: "These features can't be merged because it would damage a \"{relation}\" relation."
@@ -410,7 +410,9 @@ en:
annotation:
line: Split a line.
area: Split an area boundary.
multiple: "Split {n} lines/area boundaries."
feature:
one: "Split a feature."
other: "Split {n} features."
not_eligible: Lines can't be split at their beginning or end.
multiple_ways: There are too many lines here to split.
connected_to_hidden: This can't be split because it is connected to a hidden feature.
@@ -434,8 +436,8 @@ en:
feature:
multiple: Extract points from these features.
annotation:
single: Extracted a point.
multiple: "Extracted {n} points."
one: Extracted a point.
other: "Extracted {n} points."
too_large:
single: A point can't be extracted because not enough of this feature is visible.
multiple: Points can't be extracted because not enough of these features are visible.
@@ -500,8 +502,9 @@ en:
key: '`'
tooltip: Toggle the sidebar.
feature_info:
hidden_warning: "{count} hidden features"
hidden_details: "These features are currently hidden: {details}"
hidden_warning:
one: "{count} hidden feature"
other: "{count} hidden features"
osm_api_status:
message:
error: Unable to reach the OpenStreetMap API. Your edits are safe locally. Check your network connection.
@@ -516,7 +519,7 @@ en:
request_review: "I would like someone to review my edits."
save: Upload
cancel: Cancel
changes: "Changes ({count})"
changes: Changes
download_changes: Download osmChange file
errors: Errors
warnings: Warnings
@@ -531,9 +534,14 @@ en:
google_warning_link: https://www.openstreetmap.org/copyright
contributors:
list: "Edits by {users}"
truncated_list: "Edits by {users} and {count} others"
truncated_list:
one: "Edits by {users} and {count} other"
other: "Edits by {users} and {count} others"
info_panels:
key: I
selected:
one: "{n} selected"
other: "{n} selected"
background:
key: B
title: Background
@@ -551,7 +559,6 @@ en:
history:
key: H
title: History
selected: "{n} selected"
no_history: "No History (New Feature)"
version: Version
last_edit: Last Edit
@@ -571,7 +578,6 @@ en:
measurement:
key: M
title: Measurement
selected: "{n} selected"
geometry: Geometry
closed_line: closed line
closed_area: closed area
@@ -620,7 +626,9 @@ en:
choose_relation: Choose a parent relation
role: Role
choose: Select feature type
results: "{n} results for {search}"
results:
one: "{n} result for {search}"
other: "{n} results for {search}"
no_documentation_key: "There is no documentation available."
edit_reference: "edit/translate"
wiki_reference: View documentation
@@ -1496,11 +1504,11 @@ en:
issues:
title: Issues
key: I
list_title: "Issues ({count})"
list_title: "Issues"
errors:
list_title: "Errors ({count})"
list_title: "Errors"
warnings:
list_title: "Warnings ({count})"
list_title: "Warnings"
rules:
title: Rules
user_resolved_issues: Issues resolved by your edits
@@ -1596,10 +1604,9 @@ en:
tip: "Find unroutable roads, paths, and ferry routes"
routable:
message:
multiple: "{count} routable features are connected only to each other."
one: "{highway} is disconnected from other roads and paths"
other: "{count} routable features are connected only to each other"
reference: "All roads, paths, and ferry routes should connect to form a single routing network."
highway:
message: "{highway} is disconnected from other roads and paths"
fixme_tag:
message: '{feature} has a "Fix Me" request'
reference: 'A "fixme" tag indicates that a mapper has requested help with a feature.'
+54 -41
View File
@@ -29,13 +29,6 @@
"labeled_and_more": "{labeled} and {count} more"
},
"modes": {
"add_feature": {
"title": "Add a feature",
"description": "Search for features to add to the map.",
"key": "Tab",
"result": "{count} result",
"results": "{count} results"
},
"add_area": {
"title": "Area",
"description": "Add parks, buildings, lakes or other areas to the map.",
@@ -106,27 +99,27 @@
"copy": {
"title": "Copy",
"description": {
"single": "Set this feature for pasting.",
"multiple": "Set these features for pasting."
"one": "Set this feature for pasting.",
"other": "Set these features for pasting."
},
"annotation": {
"single": "Copied a feature.",
"multiple": "Copied {n} features."
"one": "Copied a feature.",
"other": "Copied {n} features."
},
"too_large": {
"single": "This can't be copied because not enough of it is currently visible.",
"multiple": "These can't be copied because not enough of them are currently visible."
"one": "This can't be copied because not enough of it is currently visible.",
"other": "These can't be copied because not enough of them are currently visible."
}
},
"paste": {
"title": "Paste",
"description": {
"single": "Add a duplicate {feature} here.",
"multiple": "Add {n} duplicate features here."
"one": "Add a duplicate {feature} here.",
"other": "Add {n} duplicate features here."
},
"annotation": {
"single": "Pasted a feature.",
"multiple": "Pasted {n} features."
"one": "Pasted a feature.",
"other": "Pasted {n} features."
},
"nothing_copied": "No features have been copied."
},
@@ -262,7 +255,10 @@
"line": "Deleted a line.",
"area": "Deleted an area.",
"relation": "Deleted a relation.",
"multiple": "Deleted {n} features."
"feature": {
"one": "Deleted a feature.",
"other": "Deleted {n} features."
}
},
"too_large": {
"single": "This feature can't be deleted because not enough of it is currently visible.",
@@ -298,14 +294,17 @@
},
"annotation": {
"building": {
"single": "Downgraded a feature to a basic building.",
"multiple": "Downgraded {n} features to basic buildings."
"one": "Downgraded a feature to a basic building.",
"other": "Downgraded {n} features to basic buildings."
},
"address": {
"single": "Downgraded a feature to an address.",
"multiple": "Downgraded {n} features to addresses."
"one": "Downgraded a feature to an address.",
"other": "Downgraded {n} features to addresses."
},
"multiple": "Downgraded {n} features."
"generic": {
"one": "Downgraded a feature.",
"other": "Downgraded {n} features."
}
},
"has_wikidata_tag": {
"single": "This feature can't be downgraded because it has a Wikidata tag.",
@@ -389,7 +388,10 @@
"title": "Merge",
"description": "Merge these features.",
"key": "C",
"annotation": "Merged {n} features.",
"annotation": {
"one": "Merged a feature.",
"other": "Merged {n} features."
},
"not_eligible": "These features can't be merged.",
"not_adjacent": "These features can't be merged because their endpoints aren't connected.",
"restriction": "These features can't be merged because it would damage a \"{relation}\" relation.",
@@ -536,7 +538,10 @@
"annotation": {
"line": "Split a line.",
"area": "Split an area boundary.",
"multiple": "Split {n} lines/area boundaries."
"feature": {
"one": "Split a feature.",
"other": "Split {n} features."
}
},
"not_eligible": "Lines can't be split at their beginning or end.",
"multiple_ways": "There are too many lines here to split.",
@@ -569,8 +574,8 @@
}
},
"annotation": {
"single": "Extracted a point.",
"multiple": "Extracted {n} points."
"one": "Extracted a point.",
"other": "Extracted {n} points."
},
"too_large": {
"single": "A point can't be extracted because not enough of this feature is visible.",
@@ -648,8 +653,10 @@
"tooltip": "Toggle the sidebar."
},
"feature_info": {
"hidden_warning": "{count} hidden features",
"hidden_details": "These features are currently hidden: {details}"
"hidden_warning": {
"one": "{count} hidden feature",
"other": "{count} hidden features"
}
},
"osm_api_status": {
"message": {
@@ -667,7 +674,7 @@
"request_review": "I would like someone to review my edits.",
"save": "Upload",
"cancel": "Cancel",
"changes": "Changes ({count})",
"changes": "Changes",
"download_changes": "Download osmChange file",
"errors": "Errors",
"warnings": "Warnings",
@@ -683,10 +690,17 @@
},
"contributors": {
"list": "Edits by {users}",
"truncated_list": "Edits by {users} and {count} others"
"truncated_list": {
"one": "Edits by {users} and {count} other",
"other": "Edits by {users} and {count} others"
}
},
"info_panels": {
"key": "I",
"selected": {
"one": "{n} selected",
"other": "{n} selected"
},
"background": {
"key": "B",
"title": "Background",
@@ -705,7 +719,6 @@
"history": {
"key": "H",
"title": "History",
"selected": "{n} selected",
"no_history": "No History (New Feature)",
"version": "Version",
"last_edit": "Last Edit",
@@ -727,7 +740,6 @@
"measurement": {
"key": "M",
"title": "Measurement",
"selected": "{n} selected",
"geometry": "Geometry",
"closed_line": "closed line",
"closed_area": "closed area",
@@ -782,7 +794,10 @@
"choose_relation": "Choose a parent relation",
"role": "Role",
"choose": "Select feature type",
"results": "{n} results for {search}",
"results": {
"one": "{n} result for {search}",
"other": "{n} results for {search}"
},
"no_documentation_key": "There is no documentation available.",
"edit_reference": "edit/translate",
"wiki_reference": "View documentation",
@@ -1853,12 +1868,12 @@
"issues": {
"title": "Issues",
"key": "I",
"list_title": "Issues ({count})",
"list_title": "Issues",
"errors": {
"list_title": "Errors ({count})"
"list_title": "Errors"
},
"warnings": {
"list_title": "Warnings ({count})"
"list_title": "Warnings"
},
"rules": {
"title": "Rules"
@@ -1986,12 +2001,10 @@
"tip": "Find unroutable roads, paths, and ferry routes",
"routable": {
"message": {
"multiple": "{count} routable features are connected only to each other."
"one": "{highway} is disconnected from other roads and paths",
"other": "{count} routable features are connected only to each other"
},
"reference": "All roads, paths, and ferry routes should connect to form a single routing network."
},
"highway": {
"message": "{highway} is disconnected from other roads and paths"
}
},
"fixme_tag": {
+57 -12
View File
@@ -191,25 +191,44 @@ export function coreLocalizer() {
});
};
localizer.pluralRule = function(number) {
return pluralRule(number, _localeCode);
};
// Returns the plural rule for the given `number` with the given `localeCode`.
// One of: `zero`, `one`, `two`, `few`, `many`, `other`
function pluralRule(number, localeCode) {
// modern browsers have this functionality built-in
const rules = 'Intl' in window && Intl.PluralRules && new Intl.PluralRules(localeCode);
if (rules) {
return rules.select(number);
}
// fallback to basic one/other, as in English
if (number === 1) return 'one';
return 'other';
}
/**
* Given a string identifier, try to find that string in the current
* language, and return it. This function will be called recursively
* with locale `en` if a string can not be found in the requested language.
*
* @param {string} s string identifier
* @param {string} stringId string identifier
* @param {object?} replacements token replacements and default string
* @param {string?} locale locale to use (defaults to currentLocale)
* @return {string?} localized string
*/
localizer.t = function(s, replacements, locale) {
localizer.t = function(stringId, replacements, locale) {
locale = locale || _localeCode;
// US English is the default
if (locale.toLowerCase() === 'en-us') locale = 'en';
let path = s
let path = stringId
.split('.')
.map(s => s.replace(/<TX_DOT>/g, '.'))
.map(stringId => stringId.replace(/<TX_DOT>/g, '.'))
.reverse();
let result = _localeStrings[locale];
@@ -220,24 +239,50 @@ export function coreLocalizer() {
if (result !== undefined) {
if (replacements) {
for (let k in replacements) {
const token = `{${k}}`;
const regex = new RegExp(token, 'g');
result = result.replace(regex, replacements[k]);
if (typeof result === 'object' && Object.keys(result).length) {
// If plural forms are provided, dig one level deeper based on the
// first numeric token replacement provided.
const number = Object.values(replacements).find(function(value) {
return typeof value === 'number';
});
if (number !== undefined) {
const rule = pluralRule(number, locale);
if (result[rule]) {
result = result[rule];
} else {
// We're pretty sure this should be a plural but no string
// could be found for the given rule. Just pick the first
// string and hope it makes sense.
result = Object.values(result)[0];
}
}
}
if (typeof result === 'string') {
for (let k in replacements) {
const token = `{${k}}`;
const regex = new RegExp(token, 'g');
result = result.replace(regex, replacements[k]);
}
}
}
return result;
if (typeof result === 'string') {
// found a localized string!
return result;
}
}
// no localized string found...
if (locale !== 'en') {
return localizer.t(s, replacements, 'en'); // fallback - recurse with 'en'
// Fallback to the English string since it's the only language with guaranteed 100% coverage
return localizer.t(stringId, replacements, 'en');
}
if (replacements && 'default' in replacements) {
return replacements.default; // fallback - replacements.default
// Fallback to a default value if one is specified in `replacements`
return replacements.default;
}
const missing = `Missing ${locale} translation: ${s}`;
const missing = `Missing ${locale} translation: ${stringId}`;
if (typeof console !== 'undefined') console.error(missing); // eslint-disable-line
return missing;
+3 -7
View File
@@ -5,8 +5,6 @@ import { utilArrayGroupBy, utilTotalExtent } from '../util';
export function operationCopy(context, selectedIDs) {
var _multi = selectedIDs.length === 1 ? 'single' : 'multiple';
function getFilteredIdsToCopy() {
return selectedIDs.filter(function(selectedID) {
var entity = context.graph().hasEntity(selectedID);
@@ -115,15 +113,13 @@ export function operationCopy(context, selectedIDs) {
operation.tooltip = function() {
var disable = operation.disabled();
return disable ?
t('operations.copy.' + disable + '.' + _multi) :
t('operations.copy.description' + '.' + _multi);
t('operations.copy.' + disable, { n: selectedIDs.length }) :
t('operations.copy.description', { n: selectedIDs.length });
};
operation.annotation = function() {
return selectedIDs.length === 1 ?
t('operations.copy.annotation.single') :
t('operations.copy.annotation.multiple', { n: selectedIDs.length.toString() });
return t('operations.copy.annotation', { n: selectedIDs.length });
};
+1 -1
View File
@@ -139,7 +139,7 @@ export function operationDelete(context, selectedIDs) {
operation.annotation = function() {
return selectedIDs.length === 1 ?
t('operations.delete.annotation.' + context.graph().geometry(selectedIDs[0])) :
t('operations.delete.annotation.multiple', { n: selectedIDs.length });
t('operations.delete.annotation.feature', { n: selectedIDs.length });
};
+2 -2
View File
@@ -118,9 +118,9 @@ export function operationDowngrade(context, selectedIDs) {
operation.annotation = function () {
var suffix;
if (downgradeType === 'building_address') {
suffix = 'multiple';
suffix = 'generic';
} else {
suffix = downgradeType + '.' + multi;
suffix = downgradeType;
}
return t('operations.downgrade.annotation.' + suffix, { n: affectedFeatureCount});
};
+1 -1
View File
@@ -79,7 +79,7 @@ export function operationExtract(context, selectedIDs) {
operation.annotation = function () {
return t('operations.extract.annotation.' + _amount, { n: selectedIDs.length });
return t('operations.extract.annotation', { n: selectedIDs.length });
};
+2 -6
View File
@@ -78,16 +78,12 @@ export function operationPaste(context) {
if (!ids.length) {
return t('operations.paste.nothing_copied');
}
return ids.length === 1 ?
t('operations.paste.description.single', { feature: utilDisplayLabel(oldGraph.entity(ids[0]), oldGraph) }) :
t('operations.paste.description.multiple', { n: ids.length.toString() });
return t('operations.paste.description', { feature: utilDisplayLabel(oldGraph.entity(ids[0]), oldGraph), n: ids.length });
};
operation.annotation = function() {
var ids = context.copyIDs();
return ids.length === 1 ?
t('operations.paste.annotation.single') :
t('operations.paste.annotation.multiple', { n: ids.length.toString() });
return t('operations.paste.annotation', { n: ids.length });
};
operation.id = 'paste';
+1 -1
View File
@@ -58,7 +58,7 @@ export function operationSplit(context, selectedIDs) {
operation.annotation = function() {
return ways.length === 1 ?
t('operations.split.annotation.' + context.graph().geometry(ways[0].id)) :
t('operations.split.annotation.multiple', { n: ways.length });
t('operations.split.annotation.feature', { n: ways.length });
};
+4 -2
View File
@@ -44,15 +44,17 @@ export function uiContributors(context) {
if (u.length > limit) {
var count = d3_select(document.createElement('span'));
var othersNum = u.length - limit + 1;
count.append('a')
.attr('target', '_blank')
.attr('href', function() {
return osm.changesetsURL(context.map().center(), context.map().zoom());
})
.text(u.length - limit + 1);
.text(othersNum);
wrap.append('span')
.html(t('contributors.truncated_list', { users: userList.html(), count: count.html() }));
.html(t('contributors.truncated_list', { n: othersNum, users: userList.html(), count: count.html() }));
} else {
wrap.append('span')
+1 -1
View File
@@ -122,7 +122,7 @@ export function uiPanelHistory(context) {
selection
.append('h4')
.attr('class', 'history-heading')
.text(singular || t('info_panels.history.selected', { n: selected.length }));
.text(singular || t('info_panels.selected', { n: selected.length }));
if (!singular) return;
+1 -1
View File
@@ -67,7 +67,7 @@ export function uiPanelMeasurement(context) {
});
heading = selected.length === 1 ? selected[0].id :
t('info_panels.measurement.selected', { n: selected.length.toLocaleString(locale) });
t('info_panels.selected', { n: selected.length });
if (selected.length) {
var extent = geoExtent();
+1 -1
View File
@@ -29,7 +29,7 @@ export function uiSectionChanges(context) {
.title(function() {
var history = context.history();
var summary = history.difference().summary();
return t('commit.changes', { count: summary.length });
return t('inspector.title_count', { title: t('commit.changes'), count: summary.length });
})
.disclosureContent(renderDisclosureContent);
+1 -1
View File
@@ -17,7 +17,7 @@ export function uiSectionEntityIssues(context) {
return _issues.length > 0;
})
.title(function() {
return t('issues.list_title', { count: _issues.length });
return t('inspector.title_count', { title: t('issues.list_title'), count: _issues.length });
})
.disclosureContent(renderDisclosureContent);
+1 -1
View File
@@ -19,7 +19,7 @@ export function uiSectionValidationIssues(id, severity, context) {
.title(function() {
if (!_issues) return '';
var issueCountText = _issues.length > 1000 ? '1000+' : String(_issues.length);
return t('issues.' + severity + 's.list_title', { count: issueCountText });
return t('inspector.title_count', { title: t('issues.' + severity + 's.list_title'), count: issueCountText });
})
.disclosureContent(renderDisclosureContent)
.shouldDisplay(function() {
+3 -5
View File
@@ -23,11 +23,9 @@ export function validationDisconnectedWay() {
subtype: 'highway',
severity: 'warning',
message: function(context) {
if (this.entityIds.length === 1) {
var entity = context.hasEntity(this.entityIds[0]);
return entity ? t('issues.disconnected_way.highway.message', { highway: utilDisplayLabel(entity, context.graph()) }) : '';
}
return t('issues.disconnected_way.routable.message.multiple', { count: this.entityIds.length.toString() });
var entity = this.entityIds.length && context.hasEntity(this.entityIds[0]);
var label = entity && utilDisplayLabel(entity, context.graph());
return t('issues.disconnected_way.routable.message', { count: this.entityIds.length, highway: label });
},
reference: showReference,
entityIds: Array.from(routingIslandWays).map(function(way) { return way.id; }),