Skip to content

Commit

Permalink
feat(l10n): L10N support for ExApps (#227)
Browse files Browse the repository at this point in the history
Signed-off-by: Andrey Borysenko <[email protected]>
Signed-off-by: Alexander Piskun <[email protected]>
Co-authored-by: Alexander Piskun <[email protected]>
Co-authored-by: Alexander Piskun <[email protected]>
  • Loading branch information
3 people authored Feb 20, 2024
1 parent 96bc584 commit 071b763
Show file tree
Hide file tree
Showing 21 changed files with 391 additions and 138 deletions.
6 changes: 3 additions & 3 deletions css/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,15 @@

.apps-list .toolbar {
height: 60px;
padding: 8px;
padding-left: 60px;
padding: 38px;
padding-left: 70px;
width: 100%;
background-color: var(--color-main-background);
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center
align-items: center;
}

.apps-list.installed {
Expand Down
50 changes: 50 additions & 0 deletions docs/tech_details/Translations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Translations
============

ExApps translations work in the `same way as for PHP apps <https://docs.nextcloud.com/server/latest/developer_manual/basics/front-end/l10n.html>`_ with a few adjustments
and differences.

In short, you just have to provide a ``l10n/<lang>.js`` (for front-end) and ``l10n/<lang>.json`` (for back-end) files for your app.


Front-end
*********

For the front-end part AppAPI will inject the current user's locale ``l10n/<lang>.js`` script, so that access to translated strings in kept the same as was before in PHP apps.

.. note::

ExApp l10n files are included only on the ExApp UI pages (:ref:`Top Menu <top_menu_section>`), Files (for :ref:`FileAction <file_actions_menu_section>`) and Settings (for :ref:`DeclarativeSettings <declarative_settings_section>`).


Back-end
********

For the back-end part of ExApp which can be written in different programming languages it is **up to the developer to decide** how to handle and translations files.
There is an example repository with translations: `UI example with translations <https://github.com/cloud-py-api/ui_example>`_.


Manual install
**************

For ``manual-install`` type administrator will have to manually extract to the server's `writable apps directory <https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/config_sample_php_parameters.html#apps-paths>`_ ``l10n`` folder of ExApp
(e.g. ``/path/to/apps-writable/<appid>/l10n/*.(js|json)``).
This will allow server to access ExApp's strings with translations.

.. note::

Only ``l10n`` folder must be present on the server side, ``appinfo/info.xml`` could lead to be misdetected by server as PHP app folder.



Docker install
**************

For ``docker-install`` type AppAPI will extract ``l10n`` folder to the server automatically during installation from ExApp release archive.


Translation tool
****************

To add support for your language in Nextcloud `translationtool <https://github.com/nextcloud/docker-ci/tree/master/translations/translationtool>`_ feel free to create an issue in the `nextcloud/docker-ci <https://github.com/nextcloud/docker-ci>`_ repository
or open a pull request with the changes made in ``createPotFile`` function to extract and convert translation strings.
2 changes: 2 additions & 0 deletions docs/tech_details/api/fileactionsmenu.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _file_actions_menu_section:

=================
File Actions Menu
=================
Expand Down
2 changes: 2 additions & 0 deletions docs/tech_details/api/settings.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _declarative_settings_section:

====================
Declarative Settings
====================
Expand Down
2 changes: 2 additions & 0 deletions docs/tech_details/api/topmenu.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _top_menu_section:

==============
Top Menu Entry
==============
Expand Down
1 change: 1 addition & 0 deletions docs/tech_details/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ Technical details
ApiScopes
Deployment
Authentication
Translations
api/index.rst
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\AppAPI\Listener\SabrePluginAuthInitListener;
use OCA\AppAPI\Listener\UserDeletedListener;
use OCA\AppAPI\Middleware\AppAPIAuthMiddleware;
use OCA\AppAPI\Middleware\ExAppUIL10NMiddleware;
use OCA\AppAPI\Middleware\ExAppUiMiddleware;
use OCA\AppAPI\Notifications\ExAppAdminNotifier;
use OCA\AppAPI\Notifications\ExAppNotifier;
Expand Down Expand Up @@ -63,6 +64,7 @@ public function register(IRegistrationContext $context): void {
$context->registerCapability(PublicCapabilities::class);
$context->registerMiddleware(AppAPIAuthMiddleware::class);
$context->registerMiddleware(ExAppUiMiddleware::class);
$context->registerMiddleware(ExAppUIL10NMiddleware::class, true);
$context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerNotifierService(ExAppNotifier::class);
Expand Down
14 changes: 14 additions & 0 deletions lib/Command/ExApp/Register.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\DeployActions\DockerActions;
use OCA\AppAPI\DeployActions\ManualActions;
use OCA\AppAPI\Fetcher\ExAppArchiveFetcher;
use OCA\AppAPI\Service\AppAPIService;
use OCA\AppAPI\Service\DaemonConfigService;
use OCA\AppAPI\Service\ExAppApiScopeService;
Expand Down Expand Up @@ -40,6 +41,7 @@ public function __construct(
private readonly ExAppService $exAppService,
private readonly ISecureRandom $random,
private readonly LoggerInterface $logger,
private readonly ExAppArchiveFetcher $exAppArchiveFetcher,
) {
parent::__construct();
}
Expand Down Expand Up @@ -172,6 +174,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}

if (!empty($appInfo['external-app']['translations_folder'])) {
$result = $this->exAppArchiveFetcher->installTranslations($appId, $appInfo['external-app']['translations_folder']);
if ($result) {
$this->logger->error(sprintf('Failed to install translations for %s. Reason: %s', $appId, $result));
if ($outputConsole) {
$output->writeln(sprintf('Failed to install translations for %s. Reason: %s', $appId, $result));
}
$this->exAppService->unregisterExApp($appId);
return 3;
}
}

$auth = [];
if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) {
$deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $appInfo);
Expand Down
46 changes: 34 additions & 12 deletions lib/Command/ExApp/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use OCA\AppAPI\Db\ExAppScope;
use OCA\AppAPI\DeployActions\DockerActions;
use OCA\AppAPI\DeployActions\ManualActions;
use OCA\AppAPI\Fetcher\ExAppArchiveFetcher;
use OCA\AppAPI\Service\AppAPIService;
use OCA\AppAPI\Service\DaemonConfigService;
use OCA\AppAPI\Service\ExAppApiScopeService;
Expand All @@ -30,7 +32,9 @@ public function __construct(
private readonly ExAppApiScopeService $exAppApiScopeService,
private readonly DaemonConfigService $daemonConfigService,
private readonly DockerActions $dockerActions,
private readonly ManualActions $manualActions,
private readonly LoggerInterface $logger,
private readonly ExAppArchiveFetcher $exAppArchiveFetcher,
) {
parent::__construct();
}
Expand Down Expand Up @@ -81,12 +85,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
return 2;
}
if ($daemonConfig->getAcceptsDeployId() === 'manual-install') {
$this->logger->error('For "manual-install" deployId update is done manually');

$actionsDeployIds = [
$this->dockerActions->getAcceptsDeployId(),
$this->manualActions->getAcceptsDeployId(),
];
if (!in_array($daemonConfig->getAcceptsDeployId(), $actionsDeployIds)) {
$this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId()));
if ($outputConsole) {
$output->writeln('For "manual-install" deployId update is done manually');
$output->writeln(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId()));
}
return 1;
return 2;
}

if ($exApp->getVersion() === $appInfo['version']) {
Expand All @@ -104,9 +113,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int

if ($exApp->getEnabled()) {
if ($this->service->disableExApp($exApp)) {
$this->logger->info(sprintf('ExApp %s disabled.', $appId));
$this->logger->info(sprintf('ExApp %s successfully disabled.', $appId));
if ($outputConsole) {
$output->writeln(sprintf('ExApp %s disabled.', $appId));
$output->writeln(sprintf('ExApp %s successfully disabled.', $appId));
}
}
} else {
Expand All @@ -116,6 +125,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}

if (!empty($appInfo['external-app']['translations_folder'])) {
$result = $this->exAppArchiveFetcher->installTranslations($appId, $appInfo['external-app']['translations_folder']);
if ($result) {
$this->logger->error(sprintf('Failed to install translations for %s. Reason: %s', $appId, $result));
if ($outputConsole) {
$output->writeln(sprintf('Failed to install translations for %s. Reason: %s', $appId, $result));
}
}
}

$appInfo['port'] = $exApp->getPort();
$appInfo['secret'] = $exApp->getSecret();
$auth = [];
Expand Down Expand Up @@ -161,12 +180,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$auth,
);
} else {
$this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId()));
if ($outputConsole) {
$output->writeln(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId()));
}
$this->exAppService->setStatusError($exApp, 'Daemon actions not found');
return 2;
$this->manualActions->deployExApp($exApp, $daemonConfig);
$exAppUrl = $this->manualActions->resolveExAppUrl(
$appId,
$daemonConfig->getProtocol(),
$daemonConfig->getHost(),
$daemonConfig->getDeployConfig(),
(int) $appInfo['port'],
$auth,
);
}

if (!$this->service->heartbeatExApp($exAppUrl, $auth)) {
Expand Down
111 changes: 104 additions & 7 deletions lib/Fetcher/ExAppArchiveFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
use Exception;
use OC\Archive\TAR;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\ITempManager;
use phpseclib\File\X509;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SimpleXMLElement;

/**
Expand All @@ -17,16 +20,17 @@
class ExAppArchiveFetcher {

public function __construct(
private ITempManager $tempManager,
private IClientService $clientService,
private readonly ITempManager $tempManager,
private readonly IClientService $clientService,
private readonly IConfig $config,
) {
}

/**
* Based on regular app download algorithm.
* Download ExApp release archive, verify signature extract info.xml and return its object
*/
public function downloadInfoXml(array $exAppAppstoreData): ?SimpleXMLElement {
public function downloadInfoXml(array $exAppAppstoreData, string &$extractedDir): ?SimpleXMLElement {
// 1. Signature check
if (!$this->checkExAppSignature($exAppAppstoreData)) {
return null;
Expand Down Expand Up @@ -56,9 +60,7 @@ public function downloadInfoXml(array $exAppAppstoreData): ?SimpleXMLElement {
}

$allFiles = scandir($extractDir);
$folders = array_diff($allFiles, ['.', '..']);
$folders = array_values($folders);

$folders = array_values(array_diff($allFiles, ['.', '..']));
if (count($folders) > 1) {
return null;
}
Expand All @@ -68,10 +70,58 @@ public function downloadInfoXml(array $exAppAppstoreData): ?SimpleXMLElement {
if ((string) $infoXml->id !== $exAppAppstoreData['id']) {
return null;
}

$extractedDir = $extractDir . '/' . $folders[0];
return $infoXml;
}

public function installTranslations(string $appId, string $dirTranslations): string {
if (!file_exists($dirTranslations)) {
return sprintf('Can not access directory: %s', $dirTranslations);
}
$writableAppPath = $this->getExAppFolder($appId);
if (!$writableAppPath) {
return 'Can not find writable apps path to perform installation.';
}

$installL10NPath = $writableAppPath . '/l10n';
if (file_exists($installL10NPath)) {
$this->rmdirr($installL10NPath); // Remove old l10n folder and files if exists
}
$this->copyr($dirTranslations, $installL10NPath);
return '';
}

public function getExAppFolder(string $appId): ?string {
$appsPaths = $this->config->getSystemValue('apps_paths');
$existingPath = '';
foreach ($appsPaths as $appPath) {
if ($appPath['writable'] && file_exists($appPath['path'] . '/' . $appId)) {
$existingPath = $appPath['path'] . '/' . $appId;
}
}
if (!empty($existingPath)) {
return $existingPath;
}
foreach ($appsPaths as $appPath) {
if ($appPath['writable']) {
if (mkdir($appPath['path'] . '/' . $appId)) {
return $appPath['path'] . '/' . $appId;
}
}
}
return null;
}

public function removeExAppFolder(string $appId): void {
foreach ($this->config->getSystemValue('apps_paths') as $appPath) {
if ($appPath['writable']) {
if (file_exists($appPath['path'] . '/' . $appId)) {
$this->rmdirr($appPath['path'] . '/' . $appId);
}
}
}
}

/**
* @psalm-suppress UndefinedClass
*/
Expand Down Expand Up @@ -149,4 +199,51 @@ private function splitCerts(string $cert): array {

return $matches[0];
}

public function rmdirr(string $dir, bool $deleteSelf = true): bool {
if (is_dir($dir)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($files as $fileInfo) {
if ($fileInfo->isLink()) {
unlink($fileInfo->getPathname());
} elseif ($fileInfo->isDir()) {
rmdir($fileInfo->getRealPath());
} else {
unlink($fileInfo->getRealPath());
}
}
if ($deleteSelf) {
rmdir($dir);
}
} elseif (file_exists($dir)) {
if ($deleteSelf) {
unlink($dir);
}
}
if (!$deleteSelf) {
return true;
}

return !file_exists($dir);
}

public function copyr(string $src, string $dest): void {
if (is_dir($src)) {
if (!is_dir($dest)) {
mkdir($dest);
}
$files = scandir($src);
foreach ($files as $file) {
if ($file != "." && $file != "..") {
self::copyr("$src/$file", "$dest/$file");
}
}
} elseif (file_exists($src)) {
copy($src, $dest);
}
}
}
Loading

0 comments on commit 071b763

Please sign in to comment.