gnome-shell/js/misc/extensionUtils.js
Florian Müllner 1deb13e1aa extensionUtils: Add gettext convenience helpers
We have initTranslations() for binding an extension's
gettext domain, but nothing to help with using gettext
from an extension.

Such help would be useful though, as an extension that
calls textdomain() like a normal application would
inadvertently changes the default domain for the whole
gnome-shell process.

Instead, extensions have to use domain-specific versions
of the gettext functions:

```js
const Gettext = imports.gettext.domain('my-extension');
const _ = Gettext.gettext;
```

Make this a bit easier by adding those functions directly
to the extensions object when initTranslations() is called,
then expose helper functions for calling them.

https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/2594

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/1941>
2021-08-14 18:45:25 +00:00

293 lines
8.3 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
/* exported ExtensionState, ExtensionType, getCurrentExtension,
getSettings, initTranslations, gettext, ngettext, pgettext,
openPrefs, isOutOfDate, installImporter, serializeExtension,
deserializeExtension */
// Common utils for the extension system and the extension
// preferences tool
const { Gio, GLib } = imports.gi;
const Gettext = imports.gettext;
const Config = imports.misc.config;
var ExtensionType = {
SYSTEM: 1,
PER_USER: 2,
};
var ExtensionState = {
ENABLED: 1,
DISABLED: 2,
ERROR: 3,
OUT_OF_DATE: 4,
DOWNLOADING: 5,
INITIALIZED: 6,
// Used as an error state for operations on unknown extensions,
// should never be in a real extensionMeta object.
UNINSTALLED: 99,
};
const SERIALIZED_PROPERTIES = [
'type',
'state',
'path',
'error',
'hasPrefs',
'hasUpdate',
'canChange',
];
/**
* getCurrentExtension:
*
* @returns {?object} - The current extension, or null if not called from
* an extension.
*/
function getCurrentExtension() {
let stack = new Error().stack.split('\n');
let extensionStackLine;
// Search for an occurrence of an extension stack frame
// Start at 1 because 0 is the stack frame of this function
for (let i = 1; i < stack.length; i++) {
if (stack[i].includes('/gnome-shell/extensions/')) {
extensionStackLine = stack[i];
break;
}
}
if (!extensionStackLine)
return null;
// The stack line is like:
// init([object Object])@/home/user/data/gnome-shell/extensions/u@u.id/prefs.js:8
//
// In the case that we're importing from
// module scope, the first field is blank:
// @/home/user/data/gnome-shell/extensions/u@u.id/prefs.js:8
let match = new RegExp('@(.+):\\d+').exec(extensionStackLine);
if (!match)
return null;
// local import, as the module is used from outside the gnome-shell process
// as well (not this function though)
let extensionManager = imports.ui.main.extensionManager;
let path = match[1];
let file = Gio.File.new_for_path(path);
// Walk up the directory tree, looking for an extension with
// the same UUID as a directory name.
while (file != null) {
let extension = extensionManager.lookup(file.get_basename());
if (extension !== undefined)
return extension;
file = file.get_parent();
}
return null;
}
/**
* initTranslations:
* @param {string=} domain - the gettext domain to use
*
* Initialize Gettext to load translations from extensionsdir/locale.
* If @domain is not provided, it will be taken from metadata['gettext-domain']
*/
function initTranslations(domain) {
let extension = getCurrentExtension();
if (!extension)
throw new Error('initTranslations() can only be called from extensions');
domain = domain || extension.metadata['gettext-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
let localeDir = extension.dir.get_child('locale');
if (localeDir.query_exists(null))
Gettext.bindtextdomain(domain, localeDir.get_path());
else
Gettext.bindtextdomain(domain, Config.LOCALEDIR);
Object.assign(extension, Gettext.domain(domain));
}
/**
* gettext:
* @param {string} str - the string to translate
*
* Translate @str using the extension's gettext domain
*
* @returns {string} - the translated string
*
*/
function gettext(str) {
return callExtensionGettextFunc('gettext', str);
}
/**
* ngettext:
* @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
*
* Translate @str and choose plural form using the extension's
* gettext domain
*
* @returns {string} - the translated string
*
*/
function ngettext(str, strPlural, n) {
return callExtensionGettextFunc('ngettext', str, strPlural, n);
}
/**
* pgettext:
* @param {string} context - context to disambiguate @str
* @param {string} str - the string to translate
*
* Translate @str in the context of @context using the extension's
* gettext domain
*
* @returns {string} - the translated string
*
*/
function pgettext(context, str) {
return callExtensionGettextFunc('pgettext', context, str);
}
function callExtensionGettextFunc(func, ...args) {
const extension = getCurrentExtension();
if (!extension)
throw new Error(`${func}() can only be called from extensions`);
if (!extension[func])
throw new Error(`${func}() is used without calling initTranslations() first`);
return extension[func](...args);
}
/**
* getSettings:
* @param {string=} schema - the GSettings schema id
* @returns {Gio.Settings} - a new settings object for @schema
*
* Builds and returns a GSettings schema for @schema, using schema files
* in extensionsdir/schemas. If @schema is omitted, it is taken from
* metadata['settings-schema'].
*/
function getSettings(schema) {
let extension = getCurrentExtension();
if (!extension)
throw new Error('getSettings() can only be called from extensions');
schema = schema || extension.metadata['settings-schema'];
const GioSSS = Gio.SettingsSchemaSource;
// Expect USER extensions to have a schemas/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
let schemaDir = extension.dir.get_child('schemas');
let schemaSource;
if (schemaDir.query_exists(null)) {
schemaSource = GioSSS.new_from_directory(schemaDir.get_path(),
GioSSS.get_default(),
false);
} else {
schemaSource = GioSSS.get_default();
}
let schemaObj = schemaSource.lookup(schema, true);
if (!schemaObj)
throw new Error(`Schema ${schema} could not be found for extension ${extension.metadata.uuid}. Please check your installation`);
return new Gio.Settings({ settings_schema: schemaObj });
}
/**
* openPrefs:
*
* Open the preference dialog of the current extension
*/
function openPrefs() {
const extension = getCurrentExtension();
if (!extension)
throw new Error('openPrefs() can only be called from extensions');
try {
const extensionManager = imports.ui.main.extensionManager;
extensionManager.openExtensionPrefs(extension.uuid, '', {});
} catch (e) {
if (e.name === 'ImportError')
throw new Error('openPrefs() cannot be called from preferences');
logError(e, 'Failed to open extension preferences');
}
}
function isOutOfDate(extension) {
const [major] = Config.PACKAGE_VERSION.split('.');
return !extension.metadata['shell-version'].some(v => v.startsWith(major));
}
function serializeExtension(extension) {
let obj = { ...extension.metadata };
SERIALIZED_PROPERTIES.forEach(prop => {
obj[prop] = extension[prop];
});
let res = {};
for (let key in obj) {
let val = obj[key];
let type;
switch (typeof val) {
case 'string':
type = 's';
break;
case 'number':
type = 'd';
break;
case 'boolean':
type = 'b';
break;
default:
continue;
}
res[key] = GLib.Variant.new(type, val);
}
return res;
}
function deserializeExtension(variant) {
let res = { metadata: {} };
for (let prop in variant) {
let val = variant[prop].unpack();
if (SERIALIZED_PROPERTIES.includes(prop))
res[prop] = val;
else
res.metadata[prop] = val;
}
// add the 2 additional properties to create a valid extension object, as createExtensionObject()
res.uuid = res.metadata.uuid;
res.dir = Gio.File.new_for_path(res.path);
return res;
}
function installImporter(extension) {
let oldSearchPath = imports.searchPath.slice(); // make a copy
imports.searchPath = [extension.dir.get_parent().get_path()];
// importing a "subdir" creates a new importer object that doesn't affect
// the global one
extension.imports = imports[extension.uuid];
imports.searchPath = oldSearchPath;
}