gnome-shell/js/extensions/sharedInternals.js
Florian Müllner f59d523694 extensions: Add static defineTranslationFunctions() method
The method can be used to define a set of gettext functions that
call the corresponding method of an extension.

Those functions are very similar to the gettext functions we are
exporting, except that:

 - looking up the extension is delegated to the
   Extension/Preferences class
 - it is possible to avoid examining the stack
   when called with `import.meta.url`

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2838>
2023-07-30 10:29:44 +03:00

371 lines
11 KiB
JavaScript

import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import {bindtextdomain} from 'gettext';
const Config = imports.misc.config;
let _extensionManager = null;
/**
* @param {object} extensionManager to use in utilities like `initTranslations()`
*/
export function setExtensionManager(extensionManager) {
if (_extensionManager)
throw new Error('Trying to override existing extension manager');
_extensionManager = extensionManager;
}
export class ExtensionBase {
#gettextDomain;
/**
* Look up an extension by URL (usually 'import.meta.url')
*
* @param {string} url - a file:// URL
*/
static lookupByURL(url) {
if (!url.startsWith('file://'))
return null;
// Keep the last '/' from 'file://' to force an absolute path
let path = url.slice(6);
// Walk up the directory tree, looking for an extension with
// the same UUID as a directory name.
do {
path = GLib.path_get_dirname(path);
const dirName = GLib.path_get_basename(path);
const extension = _extensionManager.lookup(dirName);
if (extension !== undefined)
return extension.stateObj;
} while (path !== '/');
return null;
}
/**
* @param {object} metadata - metadata passed in when loading the extension
*/
constructor(metadata) {
if (this.constructor === ExtensionBase)
throw new Error('ExtensionBase cannot be used directly.');
if (!metadata)
throw new Error(`${this.constructor.name} did not pass metadata to parent`);
this.metadata = metadata;
}
/**
* @type {string}
*/
get uuid() {
return this.metadata['uuid'];
}
/**
* @type {Gio.File}
*/
get dir() {
return this.metadata['dir'];
}
/**
* @type {string}
*/
get path() {
return this.metadata['path'];
}
/**
* Get a GSettings object for schema, using schema files in
* extensionsdir/schemas. If schema is omitted, it is taken
* from metadata['settings-schema'].
*
* @param {string=} schema - the GSettings schema id
*
* @returns {Gio.Settings}
*/
getSettings(schema) {
schema ||= this.metadata['settings-schema'];
// Expect USER extensions to have a schemas/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const schemaDir = this.dir.get_child('schemas');
const defaultSource = Gio.SettingsSchemaSource.get_default();
let schemaSource;
if (schemaDir.query_exists(null)) {
schemaSource = Gio.SettingsSchemaSource.new_from_directory(
schemaDir.get_path(), defaultSource, false);
} else {
schemaSource = defaultSource;
}
const schemaObj = schemaSource.lookup(schema, true);
if (!schemaObj)
throw new Error(`Schema ${schema} could not be found for extension ${this.uuid}. Please check your installation`);
return new Gio.Settings({settings_schema: schemaObj});
}
/**
* Initialize Gettext to load translations from extensionsdir/locale. If
* domain is not provided, it will be taken from metadata['gettext-domain']
*
* @param {string=} domain - the gettext domain to use
*/
initTranslations(domain) {
domain ||= this.metadata['gettext-domain'];
if (!domain)
throw new Error('initTranslations() was called without providing a valid domain');
// Expect USER extensions to have a locale/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const localeDir = this.dir.get_child('locale');
if (localeDir.query_exists(null))
bindtextdomain(domain, localeDir.get_path());
else
bindtextdomain(domain, Config.LOCALEDIR);
this.#gettextDomain = domain;
}
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext(str) {
this.#checkGettextDomain('gettext');
return GLib.dgettext(this.#gettextDomain, str);
}
/**
* Translate `str` and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} the translated string
*/
ngettext(str, strPlural, n) {
this.#checkGettextDomain('ngettext');
return GLib.dngettext(this.#gettextDomain, str, strPlural, n);
}
/**
* Translate `str` in the context of `context` using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate `str`
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
pgettext(context, str) {
this.#checkGettextDomain('pgettext');
return GLib.dpgettext2(this.#gettextDomain, context, str);
}
/**
* @param {string} func
*/
#checkGettextDomain(func) {
if (!this.#gettextDomain)
throw new Error(`${func}() is used without calling initTranslations() first`);
}
}
export class GettextWrapper {
#url;
#extensionClass;
constructor(extensionClass, url) {
this.#url = url;
this.#extensionClass = extensionClass;
}
#detectUrl() {
const basePath = '/gnome-shell/extensions/';
// Search for an occurrence of an extension stack frame
// Start at 1 because 0 is the stack frame of this function
const [, ...stack] = new Error().stack.split('\n');
const extensionLine = stack.find(
line => line.includes(basePath));
if (!extensionLine)
return null;
// The exact stack line differs depending on where the function
// was called (function or module scope), and whether it's called
// from a module or legacy import (file:// URI vs. plain path).
//
// We don't have to care about the exact composition, all we need
// is a string that can be traversed as path and contains the UUID
const path = extensionLine.slice(extensionLine.indexOf(basePath));
return `file://${path}`;
}
#lookupExtension(funcName) {
if (!this.#url)
this.#url = this.#detectUrl();
const extension = this.#extensionClass.lookupByURL(this.#url);
if (!extension)
throw new Error(`${funcName} can only be called from extensions`);
return extension;
}
#gettext(str) {
const extension = this.#lookupExtension('gettext');
return extension.gettext(str);
}
#ngettext(str, strPlural, n) {
const extension = this.#lookupExtension('ngettext');
return extension.gettext(str, strPlural, n);
}
#pgettext(context, str) {
const extension = this.#lookupExtension('pgettext');
return extension.pgettext(context, str);
}
defineTranslationFunctions() {
return {
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext: this.#gettext.bind(this),
/**
* Translate `str` and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} the translated string
*/
ngettext: this.#ngettext.bind(this),
/**
* Translate `str` in the context of `context` using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate `str`
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
pgettext: this.#pgettext.bind(this),
};
}
}
/**
* @private
*
* @returns {?object} - The current extension, or null if not called from
* an extension.
*/
function getCurrentExtension() {
const basePath = '/gnome-shell/extensions/';
// Search for an occurrence of an extension stack frame
// Start at 1 because 0 is the stack frame of this function
const [, ...stack] = new Error().stack.split('\n');
const extensionLine = stack.find(
line => line.includes(basePath));
if (!extensionLine)
return null;
// The exact stack line differs depending on where the function
// was called (function or module scope), and whether it's called
// from a module or legacy import (file:// URI vs. plain path).
//
// We don't have to care about the exact composition, all we need
// is a string that can be traversed as path and contains the UUID
let path = extensionLine.slice(extensionLine.indexOf(basePath));
// Walk up the directory tree, looking for an extension with
// the same UUID as a directory name.
do {
path = GLib.path_get_dirname(path);
const dirName = GLib.path_get_basename(path);
const extension = _extensionManager.lookup(dirName);
if (extension !== undefined)
return extension.stateObj;
} while (path !== '/');
return null;
}
/**
* Translate @str using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} - the translated string
*/
export function gettext(str) {
return callExtensionGettextFunc('gettext', str);
}
/**
* Translate @str and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} - the translated string
*/
export function ngettext(str, strPlural, n) {
return callExtensionGettextFunc('ngettext', str, strPlural, n);
}
/**
* Translate @str in the context of @context using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate @str
* @param {string} str - the string to translate
*
* @returns {string} - the translated string
*/
export function pgettext(context, str) {
return callExtensionGettextFunc('pgettext', context, str);
}
/**
* @private
* @param {string} func - function name
* @param {*[]} args - function arguments
*/
function callExtensionGettextFunc(func, ...args) {
const extension = getCurrentExtension();
if (!extension)
throw new Error(`${func}() can only be called from extensions`);
return extension[func](...args);
}