diff --git a/lib/date.ts b/lib/date.ts new file mode 100644 index 00000000..f616431a --- /dev/null +++ b/lib/date.ts @@ -0,0 +1,121 @@ +/// + +declare var window: Nextcloud.v24.WindowWithGlobals + +/** + * Get the first day of the week + * + * @return {number} + */ +export function getFirstDay(): number { + if (typeof window.firstDay === 'undefined') { + console.warn('No firstDay found') + return 1 + } + + return window.firstDay +} + +/** + * Get a list of day names (full names) + * + * @return {string[]} + */ +export function getDayNames(): string[] { + if (typeof window.dayNames === 'undefined') { + console.warn('No dayNames found') + return [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ] + } + + return window.dayNames +} + +/** + * Get a list of day names (short names) + * + * @return {string[]} + */ +export function getDayNamesShort(): string[] { + if (typeof window.dayNamesShort === 'undefined') { + console.warn('No dayNamesShort found') + return ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'] + } + + return window.dayNamesShort +} + +/** + * Get a list of day names (minified names) + * + * @return {string[]} + */ +export function getDayNamesMin(): string[] { + if (typeof window.dayNamesMin === 'undefined') { + console.warn('No dayNamesMin found') + return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] + } + + return window.dayNamesMin +} + +/** + * Get a list of month names (full names) + * + * @return {string[]} + */ +export function getMonthNames(): string[] { + if (typeof window.monthNames === 'undefined') { + console.warn('No monthNames found') + return [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ] + } + + return window.monthNames +} + +/** + * Get a list of month names (short names) + * + * @return {string[]} + */ +export function getMonthNamesShort(): string[] { + if (typeof window.monthNamesShort === 'undefined') { + console.warn('No monthNamesShort found') + return [ + 'Jan.', + 'Feb.', + 'Mar.', + 'Apr.', + 'May.', + 'Jun.', + 'Jul.', + 'Aug.', + 'Sep.', + 'Oct.', + 'Nov.', + 'Dec.', + ] + } + + return window.monthNamesShort +} diff --git a/lib/index.ts b/lib/index.ts index 1df0cb92..b8f6bafe 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,201 +1,2 @@ -/// - -declare var window: Nextcloud.v16.WindowWithGlobals - | Nextcloud.v17.WindowWithGlobals - | Nextcloud.v18.WindowWithGlobals - | Nextcloud.v19.WindowWithGlobals; - -import DOMPurify from 'dompurify' -import escapeHTML from 'escape-html' -import { getAppTranslations } from './registry' - -/** - * Returns the user's locale - */ -export function getLocale(): string { - return document.documentElement.dataset.locale || 'en' -} - -export function getCanonicalLocale(): string { - return getLocale().replace(/_/g, '-') -} - -/** - * Returns the user's language - */ -export function getLanguage(): string { - return document.documentElement.lang || 'en' -} - -interface TranslationOptions { - /** enable/disable auto escape of placeholders (by default enabled) */ - escape?: boolean, - /** enable/disable sanitization (by default enabled) */ - sanitize?: boolean, -} - -/** - * Translate a string - * - * @param {string} app the id of the app for which to translate the string - * @param {string} text the string to translate - * @param {object} vars map of placeholder key to value - * @param {number} number to replace %n with - * @param {object} [options] options object - * @return {string} - */ -export function translate(app: string, text: string, vars?: Record, number?: number, options?: TranslationOptions): string { - const defaultOptions = { - escape: true, - sanitize: true, - } - const allOptions = Object.assign({}, defaultOptions, options || {}) - - const identity = (value) => value - const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity - const optEscape = allOptions.escape ? escapeHTML : identity - - // TODO: cache this function to avoid inline recreation - // of the same function over and over again in case - // translate() is used in a loop - const _build = (text: string, vars?: Record, number?: number) => { - return text - .replace(/%n/g, '' + number) - .replace(/{([^{}]*)}/g, (match, key) => { - if (vars === undefined || !(key in vars)) return optSanitize(match) - - const r = vars[key] - if (typeof r === 'string' || typeof r === 'number') { - return optSanitize(optEscape(r)) - } else { - return optSanitize(match) - } - } - ) - } - - const bundle = getAppTranslations(app) - const translation = bundle.translations[text] || text - - if (typeof vars === 'object' || number !== undefined) { - return optSanitize(_build(translation, vars, number)) - } else { - return optSanitize(translation) - } -} - -/** - * Translate a plural string - * - * @param {string} app the id of the app for which to translate the string - * @param {string} textSingular the string to translate for exactly one object - * @param {string} textPlural the string to translate for n objects - * @param {number} number number to determine whether to use singular or plural - * @param {Object} vars of placeholder key to value - * @param {object} options options object - * @return {string} - */ - -export function translatePlural(app: string, textSingular: string, textPlural: string, number: number, vars?: object, options?: TranslationOptions): string { - const identifier = '_' + textSingular + '_::_' + textPlural + '_' - const bundle = getAppTranslations(app) - const value = bundle.translations[identifier] - - if (typeof (value) !== 'undefined') { - const translation = value - if (Array.isArray(translation)) { - const plural = bundle.pluralFunction(number) - return translate(app, translation[plural], vars, number, options) - } - } - - if (number === 1) { - return translate(app, textSingular, vars, number, options) - } else { - return translate(app, textPlural, vars, number, options) - } -} - -/** - * Get the first day of the week - * - * @return {number} - */ -export function getFirstDay(): number { - if (typeof window.firstDay === 'undefined') { - console.warn('No firstDay found') - return 1 - } - - return window.firstDay -} - -/** - * Get a list of day names (full names) - * - * @return {string[]} - */ -export function getDayNames(): string[] { - if (typeof window.dayNames === 'undefined') { - console.warn('No dayNames found') - return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] - } - - return window.dayNames -} - -/** - * Get a list of day names (short names) - * - * @return {string[]} - */ -export function getDayNamesShort(): string[] { - if (typeof window.dayNamesShort === 'undefined') { - console.warn('No dayNamesShort found') - return ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'] - } - - return window.dayNamesShort -} - -/** - * Get a list of day names (minified names) - * - * @return {string[]} - */ -export function getDayNamesMin(): string[] { - if (typeof window.dayNamesMin === 'undefined') { - console.warn('No dayNamesMin found') - return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] - } - - return window.dayNamesMin -} - -/** - * Get a list of month names (full names) - * - * @return {string[]} - */ -export function getMonthNames(): string[] { - if (typeof window.monthNames === 'undefined') { - console.warn('No monthNames found') - return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] - } - - return window.monthNames -} - -/** - * Get a list of month names (short names) - * - * @return {string[]} - */ -export function getMonthNamesShort(): string[] { - if (typeof window.monthNamesShort === 'undefined') { - console.warn('No monthNamesShort found') - return ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'] - } - - return window.monthNamesShort -} +export * from './translation' +export * from './date' diff --git a/lib/registry.ts b/lib/registry.ts index 872ebc98..7a86bd38 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -1,29 +1,109 @@ +export type Translations = Record +export type PluralFunction = (number: number) => number + declare var window: { - _oc_l10n_registry_translations: Record> - _oc_l10n_registry_plural_functions: Record number> + _oc_l10n_registry_translations: Record + _oc_l10n_registry_plural_functions: Record } interface AppTranslations { - translations: Record; - pluralFunction: (number: number) => number; + translations: Translations + pluralFunction: PluralFunction +} + +/** + * Check if translations and plural function are set for given app + * @param {string} appId the app id + * @return {boolean} + */ +export function hasAppTranslations(appId: string) { + return ( + window._oc_l10n_registry_translations?.[appId] !== undefined && + window._oc_l10n_registry_plural_functions?.[appId] !== undefined + ) +} + +/** + * Register new, or extend available, translations for an app + * @param {string} appId the app id + * @param {object} translations the translations list + * @param {Function} pluralFunction the plural function + */ +export function registerAppTranslations( + appId: string, + translations: Translations, + pluralFunction: PluralFunction +) { + if (!hasAppTranslations(appId)) { + setAppTranslations(appId, translations, pluralFunction) + } else { + extendAppTranslations(appId, translations, pluralFunction) + } +} + +/** + * Unregister all translations and plural function for given app + * @param {string} appId the app id + */ +export function unregisterAppTranslations(appId: string) { + delete window._oc_l10n_registry_translations[appId] + delete window._oc_l10n_registry_plural_functions[appId] } /** + * Get translations bundle for given app and current locale * @param {string} appId the app id * @return {object} */ export function getAppTranslations(appId: string): AppTranslations { - if (typeof window._oc_l10n_registry_translations === 'undefined' || - typeof window._oc_l10n_registry_plural_functions === 'undefined') { - console.warn('No OC L10N registry found') - return { - translations: {}, - pluralFunction: (number: number) => number - } - } - - return { - translations: window._oc_l10n_registry_translations[appId] || {}, - pluralFunction: window._oc_l10n_registry_plural_functions[appId], - } -} \ No newline at end of file + if ( + typeof window._oc_l10n_registry_translations === 'undefined' || + typeof window._oc_l10n_registry_plural_functions === 'undefined' + ) { + console.warn('No OC L10N registry found') + return { + translations: {}, + pluralFunction: (number: number) => number, + } + } + + return { + translations: window._oc_l10n_registry_translations[appId] || {}, + pluralFunction: window._oc_l10n_registry_plural_functions[appId], + } +} + +/** + * Set new translations and plural function for an app + * @param {string} appId the app id + * @param {object} translations the translations list + * @param {Function} pluralFunction the plural function + */ +function setAppTranslations( + appId: string, + translations: Translations, + pluralFunction: PluralFunction +) { + window._oc_l10n_registry_translations[appId] = translations + window._oc_l10n_registry_plural_functions[appId] = pluralFunction +} + +/** + * Extend translations for an app + * @param {string} appId the app id + * @param {object} translations the translations list + * @param {Function} [pluralFunction] the plural function (will override old value if given) + */ +function extendAppTranslations( + appId: string, + translations: Translations, + pluralFunction?: PluralFunction +) { + window._oc_l10n_registry_translations[appId] = Object.assign( + window._oc_l10n_registry_translations[appId], + translations + ) + if (typeof pluralFunction === 'function') { + window._oc_l10n_registry_plural_functions[appId] = pluralFunction + } +} diff --git a/lib/translation.ts b/lib/translation.ts new file mode 100644 index 00000000..f8100367 --- /dev/null +++ b/lib/translation.ts @@ -0,0 +1,410 @@ +import { + getAppTranslations, + hasAppTranslations, + registerAppTranslations, + unregisterAppTranslations, +} from './registry' +import type { Translations } from './registry' +import { generateFilePath } from '@nextcloud/router' + +import DOMPurify from 'dompurify' +import escapeHTML from 'escape-html' + +interface TranslationOptions { + /** enable/disable auto escape of placeholders (by default enabled) */ + escape?: boolean + /** enable/disable sanitization (by default enabled) */ + sanitize?: boolean +} + +/** + * Returns the user's locale + */ +export function getLocale(): string { + return document.documentElement.dataset.locale || 'en' +} + +export function getCanonicalLocale(): string { + return getLocale().replace(/_/g, '-') +} + +/** + * Returns the user's language + */ +export function getLanguage(): string { + return document.documentElement.lang || 'en' +} + +/** + * Translate a string + * + * @param {string} app the id of the app for which to translate the string + * @param {string} text the string to translate + * @param {object} vars map of placeholder key to value + * @param {number} number to replace %n with + * @param {object} [options] options object + * @return {string} + */ +export function translate( + app: string, + text: string, + vars?: Record, + number?: number, + options?: TranslationOptions +): string { + const defaultOptions = { + escape: true, + sanitize: true, + } + const allOptions = Object.assign({}, defaultOptions, options || {}) + + const identity = (value) => value + const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity + const optEscape = allOptions.escape ? escapeHTML : identity + + // TODO: cache this function to avoid inline recreation + // of the same function over and over again in case + // translate() is used in a loop + const _build = (text: string, vars?: Record, number?: number) => { + return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => { + if (vars === undefined || !(key in vars)) { + return optSanitize(match) + } + + const r = vars[key] + if (typeof r === 'string' || typeof r === 'number') { + return optSanitize(optEscape(r)) + } else { + return optSanitize(match) + } + }) + } + + const bundle = getAppTranslations(app) + const translation = bundle.translations[text] || text + + if (typeof vars === 'object' || number !== undefined) { + return optSanitize(_build(translation, vars, number)) + } else { + return optSanitize(translation) + } +} + +/** + * Translate a plural string + * + * @param {string} app the id of the app for which to translate the string + * @param {string} textSingular the string to translate for exactly one object + * @param {string} textPlural the string to translate for n objects + * @param {number} number number to determine whether to use singular or plural + * @param {Object} vars of placeholder key to value + * @param {object} options options object + * @return {string} + */ + +export function translatePlural( + app: string, + textSingular: string, + textPlural: string, + number: number, + vars?: object, + options?: TranslationOptions +): string { + const identifier = '_' + textSingular + '_::_' + textPlural + '_' + const bundle = getAppTranslations(app) + const value = bundle.translations[identifier] + + if (typeof value !== 'undefined') { + const translation = value + if (Array.isArray(translation)) { + const plural = bundle.pluralFunction(number) + return translate(app, translation[plural], vars, number, options) + } + } + + if (number === 1) { + return translate(app, textSingular, vars, number, options) + } else { + return translate(app, textPlural, vars, number, options) + } +} + +/** + * Load an app's translation bundle if not loaded already. + * + * @param {string} appName name of the app + * @param {Function} callback callback to be called when + * the translations are loaded + * @return {Promise} promise + */ +export function loadTranslations(appName: string, callback: (...args: any[]) => any) { + // already available ? + if (hasAppTranslations(appName) || getLocale() === 'en') { + const deferred = $.Deferred() + const promise = deferred.promise() + promise.then(callback) + deferred.resolve() + return promise + } + + const url = generateFilePath(appName, 'l10n', getLocale() + '.json') + + const promise = new Promise<{ + translations: Translations + pluralForm: string + }>((resolve, reject) => { + const request = new XMLHttpRequest() + request.open('GET', url, false) + request.onerror = (event) => { + reject({ status: request.status, statusText: request.statusText }) + } + request.onload = (event) => { + if (request.status >= 200 && request.status < 300) { + resolve(JSON.parse(request.responseText)) + } else { + reject({ + status: request.status, + statusText: request.statusText, + }) + } + } + request.send() + }) + + // load JSON translation bundle per AJAX + return promise + .then((result) => { + if (result.translations) { + register(appName, result.translations) + } + }) + .then(callback) +} + +/** + * Register an app's translation bundle. + * + * @param {string} appName name of the app + * @param {object} bundle translation bundle + */ +export function register(appName: string, bundle: Translations) { + registerAppTranslations(appName, bundle, getPlural) +} + +/** + * @private + */ +export const _unregister = unregisterAppTranslations + +/** + * Get array index of translations for a plural form + * + * + * @param {number} number the number of elements + * @return {number} 0 for the singular form(, 1 for the first plural form, ...) + */ +export function getPlural(number: number) { + let language = getLanguage() + if (language === 'pt-BR') { + // temporary set a locale for brazilian + language = 'xbr' + } + + if (typeof language === 'undefined' || language === '') { + return number === 1 ? 0 : 1 + } + + if (language.length > 3) { + language = language.substring(0, language.lastIndexOf('-')) + } + + /* + * The plural rules are derived from code of the Zend Framework (2010-09-25), + * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd). + * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) + */ + switch (language) { + case 'az': + case 'bo': + case 'dz': + case 'id': + case 'ja': + case 'jv': + case 'ka': + case 'km': + case 'kn': + case 'ko': + case 'ms': + case 'th': + case 'tr': + case 'vi': + case 'zh': + return 0 + + case 'af': + case 'bn': + case 'bg': + case 'ca': + case 'da': + case 'de': + case 'el': + case 'en': + case 'eo': + case 'es': + case 'et': + case 'eu': + case 'fa': + case 'fi': + case 'fo': + case 'fur': + case 'fy': + case 'gl': + case 'gu': + case 'ha': + case 'he': + case 'hu': + case 'is': + case 'it': + case 'ku': + case 'lb': + case 'ml': + case 'mn': + case 'mr': + case 'nah': + case 'nb': + case 'ne': + case 'nl': + case 'nn': + case 'no': + case 'oc': + case 'om': + case 'or': + case 'pa': + case 'pap': + case 'ps': + case 'pt': + case 'so': + case 'sq': + case 'sv': + case 'sw': + case 'ta': + case 'te': + case 'tk': + case 'ur': + case 'zu': + return number === 1 ? 0 : 1 + + case 'am': + case 'bh': + case 'fil': + case 'fr': + case 'gun': + case 'hi': + case 'hy': + case 'ln': + case 'mg': + case 'nso': + case 'xbr': + case 'ti': + case 'wa': + return number === 0 || number === 1 ? 0 : 1 + + case 'be': + case 'bs': + case 'hr': + case 'ru': + case 'sh': + case 'sr': + case 'uk': + return number % 10 === 1 && number % 100 !== 11 + ? 0 + : number % 10 >= 2 && + number % 10 <= 4 && + (number % 100 < 10 || number % 100 >= 20) + ? 1 + : 2 + + case 'cs': + case 'sk': + return number === 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2 + + case 'ga': + return number === 1 ? 0 : number === 2 ? 1 : 2 + + case 'lt': + return number % 10 === 1 && number % 100 !== 11 + ? 0 + : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) + ? 1 + : 2 + + case 'sl': + return number % 100 === 1 + ? 0 + : number % 100 === 2 + ? 1 + : number % 100 === 3 || number % 100 === 4 + ? 2 + : 3 + + case 'mk': + return number % 10 === 1 ? 0 : 1 + + case 'mt': + return number === 1 + ? 0 + : number === 0 || (number % 100 > 1 && number % 100 < 11) + ? 1 + : number % 100 > 10 && number % 100 < 20 + ? 2 + : 3 + + case 'lv': + return number === 0 + ? 0 + : number % 10 === 1 && number % 100 !== 11 + ? 1 + : 2 + + case 'pl': + return number === 1 + ? 0 + : number % 10 >= 2 && + number % 10 <= 4 && + (number % 100 < 12 || number % 100 > 14) + ? 1 + : 2 + + case 'cy': + return number === 1 + ? 0 + : number === 2 + ? 1 + : number === 8 || number === 11 + ? 2 + : 3 + + case 'ro': + return number === 1 + ? 0 + : number === 0 || (number % 100 > 0 && number % 100 < 20) + ? 1 + : 2 + + case 'ar': + return number === 0 + ? 0 + : number === 1 + ? 1 + : number === 2 + ? 2 + : number % 100 >= 3 && number % 100 <= 10 + ? 3 + : number % 100 >= 11 && number % 100 <= 99 + ? 4 + : 5 + + default: + return 0 + } +} diff --git a/package-lock.json b/package-lock.json index 0bbe4d51..c2da0806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.6.0", "license": "GPL-3.0-or-later", "dependencies": { + "@nextcloud/router": "^2.0.0", "core-js": "^3.6.4", "dompurify": "^2.4.1", "escape-html": "^1.0.3", @@ -2727,6 +2728,18 @@ "npm": "^7.0.0 || ^8.0.0" } }, + "node_modules/@nextcloud/router": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz", + "integrity": "sha512-qLRxTjZS6y9NUPmU6X3Ega5qHPeEx4kCgqwo0I6Y9wV71EGGi9zPnWDsqmmmJj8RkDp30jcfGNWCTwbPAebTDA==", + "dependencies": { + "core-js": "^3.6.4" + }, + "engines": { + "node": "^16.0.0", + "npm": "^7.0.0 || ^8.0.0" + } + }, "node_modules/@nextcloud/typings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.5.0.tgz", @@ -10410,6 +10423,14 @@ "integrity": "sha512-1Tpkof2e9Q0UicHWahQnXXrubJoqyiaqsH9G52v3cjGeVeH3BCfa1FOa41eBwBSFe2/Jxj/wCH2YVLgIXpWbBg==", "dev": true }, + "@nextcloud/router": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.0.1.tgz", + "integrity": "sha512-qLRxTjZS6y9NUPmU6X3Ega5qHPeEx4kCgqwo0I6Y9wV71EGGi9zPnWDsqmmmJj8RkDp30jcfGNWCTwbPAebTDA==", + "requires": { + "core-js": "^3.6.4" + } + }, "@nextcloud/typings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/typings/-/typings-1.5.0.tgz", diff --git a/package.json b/package.json index f2e57881..131c7426 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "url": "https://github.com/nextcloud/nextcloud-l10n" }, "dependencies": { + "@nextcloud/router": "^2.0.0", "core-js": "^3.6.4", "dompurify": "^2.4.1", "escape-html": "^1.0.3",