Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/build-package.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ param (
[string]$Version
)

$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did"
$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did", "fiftyone.pipeline.translation"

$noRemote = "fiftyone.pipeline.core"

Expand Down
2 changes: 1 addition & 1 deletion ci/build-project.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ param (
[string]$RepoName
)

$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did";
$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did", "fiftyone.pipeline.translation";

./node/build-project.ps1 -RepoName $RepoName -Packages $packages

Expand Down
2 changes: 1 addition & 1 deletion ci/update-packages.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ param (
[string]$RepoName
)

$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did"
$packages = "fiftyone.pipeline.cloudrequestengine", "fiftyone.pipeline.core", "fiftyone.pipeline.engines", "fiftyone.pipeline.engines.fiftyone", "fiftyone.pipeline.did", "fiftyone.pipeline.translation"

./node/update-packages.ps1 -RepoName $RepoName -Packages $packages

Expand Down
29 changes: 29 additions & 0 deletions fiftyone.pipeline.translation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* *********************************************************************
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
*
* This Original Work is licensed under the European Union Public Licence
* (EUPL) v.1.2 and is subject to its terms as set out below.
*
* If a copy of the EUPL was not distributed with this file, You can obtain
* one at https://opensource.org/licenses/EUPL-1.2.
*
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
* amended by the European Commission) shall be deemed incompatible for
* the purposes of the Work and the provisions of the compatibility
* clause in Article 5 of the EUPL shall not apply.
*
* If using the Work as, or as part of, a network application, by
* including the attribution notice(s) required under Article 5 of the EUPL
* in the end user terms of the application under an appropriate heading,
* such notice(s) shall fulfill the requirements of that article.
* ********************************************************************* */

module.exports = {
TranslationEngine: require('./translationEngine'),
TranslationData: require('./translationData'),
Translator: require('./translator'),
Languages: require('./languages'),
MissingTranslationBehavior: require('./missingTranslationBehavior')
};
163 changes: 163 additions & 0 deletions fiftyone.pipeline.translation/languages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* *********************************************************************
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
*
* This Original Work is licensed under the European Union Public Licence
* (EUPL) v.1.2 and is subject to its terms as set out below.
*
* If a copy of the EUPL was not distributed with this file, You can obtain
* one at https://opensource.org/licenses/EUPL-1.2.
*
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
* amended by the European Commission) shall be deemed incompatible for
* the purposes of the Work and the provisions of the compatibility
* clause in Article 5 of the EUPL shall not apply.
*
* If using the Work as, or as part of, a network application, by
* including the attribution notice(s) required under Article 5 of the EUPL
* in the end user terms of the application under an appropriate heading,
* such notice(s) shall fulfill the requirements of that article.
* ********************************************************************* */

/**
* @typedef {import('./translator')} Translator
*/

/**
* Set of translators for one or more languages, with static helpers for
* parsing Accept-Language headers and resolving language tags against the
* available locales.
*/
class Languages {
/**
* Constructor.
*/
constructor () {
// Keyed on the original-case locale (e.g. 'fr_FR') so the matched locale
// can be reported back exactly as it was registered.
this.translators = {};
}

/**
* Add a language and its translator to the set.
*
* @param {string} locale locale code for the language e.g. 'en_GB', 'fr_FR'
* @param {Translator} translator translator for the language
*/
addLanguage (locale, translator) {
if (locale === null || locale === undefined || translator === null ||
translator === undefined) {
throw 'A locale and translator must be supplied.';
}
this.translators[locale] = translator;
}

/**
* Get the translator and matched locale for the specified language, if one
* is available.
*
* @param {string} language a locale code (e.g. 'fr_FR') or a full
* Accept-Language header value (e.g. 'es,de-DE;q=0.8,en;q=0.5')
* @returns {object|null} an object with `translator` and `matchedLocale`
* properties, or null if no match was found
*/
getTranslator (language) {
const matched = Languages.tryResolveLocale(
language, Object.keys(this.translators));
if (matched !== null) {
return { translator: this.translators[matched], matchedLocale: matched };
}
return null;
}

/**
* Parse an Accept-Language header value (e.g. 'es,de-DE;q=0.8,en;q=0.5')
* into an ordered list of normalized language tags. Tags are ordered by
* quality (descending) with dashes replaced by underscores (e.g. 'en-GB'
* becomes 'en_GB').
*
* @param {string} acceptLanguage the raw Accept-Language header value
* @returns {Array<string>} ordered list of normalized tags, highest
* preference first
*/
static parseAcceptLanguage (acceptLanguage) {
if (typeof acceptLanguage !== 'string' || acceptLanguage.trim() === '') {
return [];
}
return acceptLanguage.split(',')
.map((part, index) => {
const segments = part.split(';');
let quality = 1;
for (let i = 1; i < segments.length; i++) {
const segment = segments[i].trim();
if (segment.toLowerCase().startsWith('q=')) {
const parsed = parseFloat(segment.substring(2));
if (!isNaN(parsed)) {
quality = parsed;
}
}
}
return { value: segments[0], quality, index };
})
// Order by quality descending, keeping the original order for ties
// (Array.prototype.sort is stable).
.sort((a, b) => b.quality - a.quality || a.index - b.index)
.map((item) => item.value.trim().replace(/-/g, '_'))
.filter((value) => value !== '');
}

/**
* Resolve an Accept-Language header value against a set of available locale
* keys, returning the best matching locale. Handles both exact locale
* matches (e.g. 'fr_FR') and 2-character language code fallbacks (e.g. 'fr'
* matching 'fr_FR').
*
* If the highest-priority language matches the base language (default 'en')
* resolution stops immediately and returns null, since the source values are
* already in the base language and no translation is needed. This prevents
* falling through to a lower-priority language.
*
* @param {string} acceptLanguage the raw Accept-Language header value
* @param {Array<string>} availableLocales the available locale keys
* @param {string} [baseLanguage] the 2-char code of the base language that
* source values are already in; defaults to 'en'
* @returns {string|null} the matched locale key (original case), or null
*/
static tryResolveLocale (acceptLanguage, availableLocales, baseLanguage) {
if (baseLanguage === undefined) {
baseLanguage = 'en';
}
const candidates = Languages.parseAcceptLanguage(acceptLanguage);
for (const candidate of candidates) {
const lowerCandidate = candidate.toLowerCase();

// Try an exact match first.
const exact = availableLocales.find(
(key) => key.toLowerCase() === lowerCandidate);
if (exact !== undefined) {
return exact;
}

// No exact match. If this candidate's language is the base language,
// the source values are already in that language - stop and return null
// rather than falling through to a lower-priority language.
if (baseLanguage &&
lowerCandidate.startsWith(baseLanguage.toLowerCase())) {
return null;
}

// Try a 2-character language code fallback.
if (candidate.length === 2) {
const fallback = availableLocales.find(
(key) => key.toLowerCase().startsWith(lowerCandidate));
if (fallback !== undefined) {
return fallback;
}
}
}
return null;
}
}

module.exports = Languages;
40 changes: 40 additions & 0 deletions fiftyone.pipeline.translation/missingTranslationBehavior.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* *********************************************************************
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
*
* This Original Work is licensed under the European Union Public Licence
* (EUPL) v.1.2 and is subject to its terms as set out below.
*
* If a copy of the EUPL was not distributed with this file, You can obtain
* one at https://opensource.org/licenses/EUPL-1.2.
*
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
* amended by the European Commission) shall be deemed incompatible for
* the purposes of the Work and the provisions of the compatibility
* clause in Article 5 of the EUPL shall not apply.
*
* If using the Work as, or as part of, a network application, by
* including the attribution notice(s) required under Article 5 of the EUPL
* in the end user terms of the application under an appropriate heading,
* such notice(s) shall fulfill the requirements of that article.
* ********************************************************************* */

/**
* Defines the behaviour of a translation when no translation is available
* for a value.
*
* - `Original`: return the input value unchanged.
* - `EmptyString`: return an empty string.
* - `FlowError`: add a not-found error to the flow data and return null.
*
* @readonly
* @enum {string}
*/
const MissingTranslationBehavior = {
Original: 'Original',
EmptyString: 'EmptyString',
FlowError: 'FlowError'
};

module.exports = MissingTranslationBehavior;
38 changes: 38 additions & 0 deletions fiftyone.pipeline.translation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "fiftyone.pipeline.translation",
"version": "1.0.21",
"description": "Translation flow element for the 51Degrees Pipeline API. Translates string based property values from one element to another using YAML translation files.",
"keywords": [
"51degrees",
"pipeline",
"translation",
"language"
],
"main": "index.js",
"types": "types/index.d.ts",
"directories": {
"test": "tests"
},
"scripts": {
"test": "jest"
},
"author": "51Degrees Engineering <engineering@51degrees.com>",
"dependencies": {
"fiftyone.pipeline.core": "file:../fiftyone.pipeline.core",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/jest": "^27.5.2",
"@types/node": "^20.14.10",
"jest": "^27.5.1",
"typescript": "^5.5.3"
},
"license": "EUPL-1.2",
"bugs": {
"url": "https://github.com/51Degrees/pipeline-node/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/51Degrees/pipeline-node"
}
}
35 changes: 35 additions & 0 deletions fiftyone.pipeline.translation/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
![51Degrees](https://51degrees.com/img/logo.png?utm_source=github&utm_medium=readme&utm_campaign=pipeline-node&utm_content=fiftyone.pipeline.translation-readme.md&utm_term=top "Data rewards the curious") **51Degrees Pipeline Translation**

[Developer Documentation](https://51degrees.com/pipeline-node/index.html?utm_source=github&utm_medium=readme&utm_campaign=pipeline-node&utm_content=fiftyone.pipeline.translation-readme.md&utm_term=top "developer documentation")

## Introduction

The 51Degrees Pipeline API is a generic web request intelligence and data processing solution with the ability to add a range of 51Degrees and/or custom plug ins (Engines)

## This package - fiftyone.pipeline.translation

This package provides a generic translation `flow element`. A translation engine reads string based properties from an upstream element and writes translated values under its own element data key. The supported value shapes are a string, a list of strings and a weighted list of strings (the `{ value, weight, rawWeight }` shape surfaced by 51Degrees engines), as well as an `AspectPropertyValue` wrapping any of those. The shape of an output property matches its input, and the weights of a weighted list are preserved.

Translations are supplied as YAML files, where the file name defines the locale contained in the file (for example `countries.fr_FR.yml`). The language to translate to can be fixed, or resolved from the evidence using the `query.translation`, `query.accept-language` and `header.accept-language` keys (in that precedence order). English is treated as the base language: when it is the highest priority language no translation is performed and the source values are returned unchanged.

This package is used by [`fiftyone.ipintelligence.translation`](https://github.com/51Degrees/ip-intelligence-node) to turn weighted country code lists into localized country names.

## Installation

```
npm install fiftyone.pipeline.translation
```

## Tests

To run tests you will need to install the `jest` library.

```
npm install jest --global
```

Then, navigate to the module directory and execute:

```
npm test
```
Loading
Loading