mirror of https://github.com/nextcloud/server
feat(updatenotification): Add notification for users when apps are updated
* Open app changelog dialog when available (webui) * Fallback to open changelog page for mobile clients Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
d9d3448e23
commit
fa14daf968
|
@ -3,8 +3,8 @@
|
|||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>updatenotification</id>
|
||||
<name>Update notification</name>
|
||||
<summary>Displays update notifications for Nextcloud and provides the SSO for the updater.</summary>
|
||||
<description>Displays update notifications for Nextcloud and provides the SSO for the updater.</description>
|
||||
<summary>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</summary>
|
||||
<description>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</description>
|
||||
<version>1.19.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Lukas Reschke</author>
|
||||
|
|
|
@ -27,8 +27,11 @@ return [
|
|||
'routes' => [
|
||||
['name' => 'Admin#createCredentials', 'url' => '/credentials', 'verb' => 'GET'],
|
||||
['name' => 'Admin#setChannel', 'url' => '/channel', 'verb' => 'POST'],
|
||||
// Fallback app changelog information for mobile clients
|
||||
['name' => 'Changelog#showChangelog', 'url' => '/changelog/{app}', 'verb' => 'GET'],
|
||||
],
|
||||
'ocs' => [
|
||||
['name' => 'API#getAppList', 'url' => '/api/{apiVersion}/applist/{newVersion}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
|
||||
['name' => 'API#getAppChangelogEntry', 'url' => '/api/{apiVersion}/changelog/{appId}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
|
||||
],
|
||||
];
|
||||
|
|
|
@ -8,12 +8,18 @@ $baseDir = $vendorDir;
|
|||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'OCA\\UpdateNotification\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => $baseDir . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => $baseDir . '/../lib/BackgroundJob/ResetToken.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => $baseDir . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
|
||||
'OCA\\UpdateNotification\\Command\\Check' => $baseDir . '/../lib/Command/Check.php',
|
||||
'OCA\\UpdateNotification\\Controller\\APIController' => $baseDir . '/../lib/Controller/APIController.php',
|
||||
'OCA\\UpdateNotification\\Controller\\AdminController' => $baseDir . '/../lib/Controller/AdminController.php',
|
||||
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => $baseDir . '/../lib/Notification/BackgroundJob.php',
|
||||
'OCA\\UpdateNotification\\Controller\\ChangelogController' => $baseDir . '/../lib/Controller/ChangelogController.php',
|
||||
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => $baseDir . '/../lib/Listener/AppUpdateEventListener.php',
|
||||
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => $baseDir . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
|
||||
'OCA\\UpdateNotification\\Manager' => $baseDir . '/../lib/Manager.php',
|
||||
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => $baseDir . '/../lib/Notification/AppUpdateNotifier.php',
|
||||
'OCA\\UpdateNotification\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
|
||||
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => $baseDir . '/../lib/ResetTokenBackgroundJob.php',
|
||||
'OCA\\UpdateNotification\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\UpdateNotification\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
|
||||
'OCA\\UpdateNotification\\UpdateChecker' => $baseDir . '/../lib/UpdateChecker.php',
|
||||
|
|
|
@ -23,12 +23,18 @@ class ComposerStaticInitUpdateNotification
|
|||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'OCA\\UpdateNotification\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => __DIR__ . '/..' . '/../lib/BackgroundJob/ResetToken.php',
|
||||
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
|
||||
'OCA\\UpdateNotification\\Command\\Check' => __DIR__ . '/..' . '/../lib/Command/Check.php',
|
||||
'OCA\\UpdateNotification\\Controller\\APIController' => __DIR__ . '/..' . '/../lib/Controller/APIController.php',
|
||||
'OCA\\UpdateNotification\\Controller\\AdminController' => __DIR__ . '/..' . '/../lib/Controller/AdminController.php',
|
||||
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => __DIR__ . '/..' . '/../lib/Notification/BackgroundJob.php',
|
||||
'OCA\\UpdateNotification\\Controller\\ChangelogController' => __DIR__ . '/..' . '/../lib/Controller/ChangelogController.php',
|
||||
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => __DIR__ . '/..' . '/../lib/Listener/AppUpdateEventListener.php',
|
||||
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => __DIR__ . '/..' . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
|
||||
'OCA\\UpdateNotification\\Manager' => __DIR__ . '/..' . '/../lib/Manager.php',
|
||||
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => __DIR__ . '/..' . '/../lib/Notification/AppUpdateNotifier.php',
|
||||
'OCA\\UpdateNotification\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
|
||||
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => __DIR__ . '/..' . '/../lib/ResetTokenBackgroundJob.php',
|
||||
'OCA\\UpdateNotification\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\UpdateNotification\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
|
||||
'OCA\\UpdateNotification\\UpdateChecker' => __DIR__ . '/..' . '/../lib/UpdateChecker.php',
|
||||
|
|
|
@ -10,6 +10,7 @@ declare(strict_types=1);
|
|||
* @author Lukas Reschke <lukas@statuscode.ch>
|
||||
* @author Morris Jobke <hey@morrisjobke.de>
|
||||
* @author Roeland Jago Douma <roeland@famdouma.nl>
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
|
@ -29,13 +30,18 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace OCA\UpdateNotification\AppInfo;
|
||||
|
||||
use OCA\UpdateNotification\Listener\AppUpdateEventListener;
|
||||
use OCA\UpdateNotification\Listener\BeforeTemplateRenderedEventListener;
|
||||
use OCA\UpdateNotification\Notification\AppUpdateNotifier;
|
||||
use OCA\UpdateNotification\Notification\Notifier;
|
||||
use OCA\UpdateNotification\UpdateChecker;
|
||||
use OCP\App\Events\AppUpdateEvent;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IUser;
|
||||
|
@ -46,12 +52,18 @@ use Psr\Container\ContainerInterface;
|
|||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_NAME = 'updatenotification';
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct('updatenotification', []);
|
||||
parent::__construct(self::APP_NAME, []);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerNotifierService(Notifier::class);
|
||||
$context->registerNotifierService(AppUpdateNotifier::class);
|
||||
|
||||
$context->registerEventListener(AppUpdateEvent::class, AppUpdateEventListener::class);
|
||||
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedEventListener::class);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\UpdateNotification\BackgroundJob;
|
||||
|
||||
use OCA\UpdateNotification\AppInfo\Application;
|
||||
use OCA\UpdateNotification\Manager;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\QueuedJob;
|
||||
use OCP\IConfig;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Notification\IManager;
|
||||
use OCP\Notification\INotification;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AppUpdatedNotifications extends QueuedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private IConfig $config,
|
||||
private IManager $notificationManager,
|
||||
private IUserManager $userManager,
|
||||
private IAppManager $appManager,
|
||||
private LoggerInterface $logger,
|
||||
private Manager $manager,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{appId: string, timestamp: int} $argument
|
||||
*/
|
||||
protected function run(mixed $argument): void {
|
||||
$appId = $argument['appId'];
|
||||
$timestamp = $argument['timestamp'];
|
||||
$dateTime = $this->time->getDateTime();
|
||||
$dateTime->setTimestamp($timestamp);
|
||||
|
||||
$this->logger->debug(
|
||||
'Running background job to create app update notifications for "' . $appId . '"',
|
||||
[
|
||||
'app' => Application::APP_NAME,
|
||||
],
|
||||
);
|
||||
|
||||
if ($this->manager->getChangelogFile($appId, 'en') === null) {
|
||||
$this->logger->debug('Skipping app updated notification - no changelog provided');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->stopPreviousNotifications($appId);
|
||||
|
||||
// Create new notifications
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_NAME)
|
||||
->setDateTime($dateTime)
|
||||
->setSubject('app_updated', [$appId])
|
||||
->setObject('app_updated', $appId);
|
||||
|
||||
$this->notifyUsers($appId, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all previous notifications users might not have dismissed until now
|
||||
* @param string $appId The app to stop update notifications for
|
||||
*/
|
||||
private function stopPreviousNotifications(string $appId): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_NAME)
|
||||
->setObject('app_updated', $appId);
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all users for which the updated app is enabled
|
||||
*/
|
||||
private function notifyUsers(string $appId, INotification $notification): void {
|
||||
$guestsEnabled = class_exists('\OCA\Guests\UserBackend');
|
||||
|
||||
$isDefer = $this->notificationManager->defer();
|
||||
|
||||
// Notify all seen users about the app update
|
||||
$this->userManager->callForSeenUsers(function (IUser $user) use ($guestsEnabled, $appId, $notification) {
|
||||
if ($guestsEnabled && ($user->getBackend() instanceof ('\OCA\Guests\UserBackend'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->appManager->isEnabledForUser($appId, $user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification->setUser($user->getUID());
|
||||
$this->notificationManager->notify($notification);
|
||||
});
|
||||
|
||||
// If we enabled the defer we call the flush
|
||||
if ($isDefer) {
|
||||
$this->notificationManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ declare(strict_types=1);
|
|||
*
|
||||
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
* @author Joas Schilling <coding@schilljs.com>
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
|
@ -27,6 +28,7 @@ declare(strict_types=1);
|
|||
namespace OCA\UpdateNotification\Controller;
|
||||
|
||||
use OC\App\AppStore\Fetcher\AppFetcher;
|
||||
use OCA\UpdateNotification\Manager;
|
||||
use OCA\UpdateNotification\ResponseDefinitions;
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\App\IAppManager;
|
||||
|
@ -43,21 +45,6 @@ use OCP\L10N\IFactory;
|
|||
*/
|
||||
class APIController extends OCSController {
|
||||
|
||||
/** @var IConfig */
|
||||
protected $config;
|
||||
|
||||
/** @var IAppManager */
|
||||
protected $appManager;
|
||||
|
||||
/** @var AppFetcher */
|
||||
protected $appFetcher;
|
||||
|
||||
/** @var IFactory */
|
||||
protected $l10nFactory;
|
||||
|
||||
/** @var IUserSession */
|
||||
protected $userSession;
|
||||
|
||||
/** @var string */
|
||||
protected $language;
|
||||
|
||||
|
@ -73,20 +60,17 @@ class APIController extends OCSController {
|
|||
'twofactor_totp' => 25,
|
||||
];
|
||||
|
||||
public function __construct(string $appName,
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
IConfig $config,
|
||||
IAppManager $appManager,
|
||||
AppFetcher $appFetcher,
|
||||
IFactory $l10nFactory,
|
||||
IUserSession $userSession) {
|
||||
protected IConfig $config,
|
||||
protected IAppManager $appManager,
|
||||
protected AppFetcher $appFetcher,
|
||||
protected IFactory $l10nFactory,
|
||||
protected IUserSession $userSession,
|
||||
protected Manager $manager,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
|
||||
$this->config = $config;
|
||||
$this->appManager = $appManager;
|
||||
$this->appFetcher = $appFetcher;
|
||||
$this->l10nFactory = $l10nFactory;
|
||||
$this->userSession = $userSession;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -178,4 +162,40 @@ class APIController extends OCSController {
|
|||
'appName' => $name ?? $appId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changelog entry for an app
|
||||
*
|
||||
* @param string $appId App to search changelog entry for
|
||||
* @param string|null $version The version to search the changelog entry for (defaults to the latest installed)
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{appName: string, content: string, version: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
|
||||
*
|
||||
* 200: Changelog entry returned
|
||||
* 404: No changelog found
|
||||
*/
|
||||
public function getAppChangelogEntry(string $appId, ?string $version = null): DataResponse {
|
||||
$version = $version ?? $this->appManager->getAppVersion($appId);
|
||||
$changes = $this->manager->getChangelog($appId, $version);
|
||||
|
||||
if ($changes === null) {
|
||||
return new DataResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Remove version headline
|
||||
/** @var string[] */
|
||||
$changes = explode("\n", $changes, 2);
|
||||
$changes = trim(end($changes));
|
||||
|
||||
// Get app info for localized app name
|
||||
$info = $this->appManager->getAppInfo($appId) ?? [];
|
||||
/** @var string */
|
||||
$appName = $info['name'] ?? $appId;
|
||||
|
||||
return new DataResponse([
|
||||
'appName' => $appName,
|
||||
'content' => $changes,
|
||||
'version' => $version,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
namespace OCA\UpdateNotification\Controller;
|
||||
|
||||
use OCA\UpdateNotification\Manager;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IRequest;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
class ChangelogController extends Controller {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private Manager $manager,
|
||||
private IAppManager $appManager,
|
||||
private IInitialState $initialState,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* This page is only used for clients not support showing the app changelog feature in-app and thus need to show it on a dedicated page.
|
||||
* @param string $app App to show the changelog for
|
||||
* @param string|null $version Version entry to show (defaults to latest installed)
|
||||
* @NoCSRFRequired
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function showChangelog(string $app, ?string $version = null): TemplateResponse {
|
||||
$version = $version ?? $this->appManager->getAppVersion($app);
|
||||
$appInfo = $this->appManager->getAppInfo($app) ?? [];
|
||||
$appName = $appInfo['name'] ?? $app;
|
||||
|
||||
$changes = $this->manager->getChangelog($app, $version) ?? '';
|
||||
// Remove version headline
|
||||
/** @var string[] */
|
||||
$changes = explode("\n", $changes, 2);
|
||||
$changes = trim(end($changes));
|
||||
|
||||
$this->initialState->provideInitialState('changelog', [
|
||||
'appName' => $appName,
|
||||
'appVersion' => $version,
|
||||
'text' => $changes,
|
||||
]);
|
||||
|
||||
\OCP\Util::addScript($this->appName, 'view-changelog-page');
|
||||
return new TemplateResponse($this->appName, 'empty');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\UpdateNotification\Listener;
|
||||
|
||||
use OCA\UpdateNotification\AppInfo\Application;
|
||||
use OCA\UpdateNotification\BackgroundJob\AppUpdatedNotifications;
|
||||
use OCP\App\Events\AppUpdateEvent;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** @template-implements IEventListener<AppUpdateEvent> */
|
||||
class AppUpdateEventListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
private IJobList $jobList,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AppUpdateEvent $event
|
||||
*/
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof AppUpdateEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->jobList->getJobsIterator(AppUpdatedNotifications::class, null, 0) as $job) {
|
||||
// Remove waiting notification jobs for this app
|
||||
if ($job->getArgument()['appId'] === $event->getAppId()) {
|
||||
$this->jobList->remove($job);
|
||||
}
|
||||
}
|
||||
|
||||
$this->jobList->add(AppUpdatedNotifications::class, [
|
||||
'appId' => $event->getAppId(),
|
||||
'timestamp' => time(),
|
||||
]);
|
||||
|
||||
$this->logger->debug(
|
||||
'Scheduled app update notification for "' . $event->getAppId() . '"',
|
||||
[
|
||||
'app' => Application::APP_NAME,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\UpdateNotification\Listener;
|
||||
|
||||
use OCA\UpdateNotification\AppInfo\Application;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
|
||||
class BeforeTemplateRenderedEventListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BeforeTemplateRenderedEvent $event
|
||||
*/
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof BeforeTemplateRenderedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle logged in users
|
||||
if (!$event->isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore when notifications are disabled
|
||||
if (!$this->appManager->isEnabledForUser('notifications')) {
|
||||
return;
|
||||
}
|
||||
|
||||
\OCP\Util::addInitScript(Application::APP_NAME, 'init');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\UpdateNotification;
|
||||
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use OCP\L10N\IFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class Manager {
|
||||
|
||||
private ?IUser $currentUser;
|
||||
|
||||
public function __construct(
|
||||
IUserSession $currentSession,
|
||||
private IAppManager $appManager,
|
||||
private IFactory $l10NFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
$this->currentUser = $currentSession->getUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the changelog entry for the given appId
|
||||
* @param string $appId The app for which to query the entry
|
||||
* @param string $version The version for which to query the changelog entry
|
||||
* @param ?string $languageCode The language in which to query the changelog (defaults to current user language and fallsback to English)
|
||||
* @return string|null Either the changelog entry or null if no changelog is found
|
||||
*/
|
||||
public function getChangelog(string $appId, string $version, ?string $languageCode = null): string|null {
|
||||
if ($languageCode === null) {
|
||||
$languageCode = $this->l10NFactory->getUserLanguage($this->currentUser);
|
||||
}
|
||||
|
||||
$path = $this->getChangelogFile($appId, $languageCode);
|
||||
if ($path === null) {
|
||||
$this->logger->debug('No changelog file found for app ' . $appId . ' and language code ' . $languageCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
$changes = $this->retrieveChangelogEntry($path, $version);
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the changelog file in the requested language or fallback to English
|
||||
* @param string $appId The app to load the changelog for
|
||||
* @param string $languageCode The language code to search
|
||||
* @return string|null Either the file path or null if not found
|
||||
*/
|
||||
public function getChangelogFile(string $appId, string $languageCode): string|null {
|
||||
try {
|
||||
$appPath = $this->appManager->getAppPath($appId);
|
||||
$files = ["CHANGELOG.$languageCode.md", 'CHANGELOG.en.md'];
|
||||
foreach ($files as $file) {
|
||||
$path = $appPath . '/' . $file;
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and return null below
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a log entry from the changelog
|
||||
* @param string $path The path to the changlog file
|
||||
* @param string $version The version to query (make sure to only pass in "{major}.{minor}(.{patch}" format)
|
||||
*/
|
||||
protected function retrieveChangelogEntry(string $path, string $version): string|null {
|
||||
$matches = [];
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
$this->logger->debug('Could not open changelog file', ['file-path' => $path]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = preg_match_all('/^## (?:\[)?(?:v)?(\d+\.\d+(\.\d+)?)/m', $content, $matches, PREG_OFFSET_CAPTURE);
|
||||
if ($result === false || $result === 0) {
|
||||
$this->logger->debug('No entries in changelog found', ['file_path' => $path]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the key of the match that equals the requested version
|
||||
$index = array_key_first(
|
||||
// Get the array containing the match that equals the requested version, keys are preserved so: [1 => '1.2.4']
|
||||
array_filter(
|
||||
// This is the array of the versions found, like ['1.2.3', '1.2.4']
|
||||
$matches[1],
|
||||
// Callback to filter only version that matches the requested version
|
||||
fn (array $match) => version_compare($match[0], $version, '=='),
|
||||
)
|
||||
);
|
||||
|
||||
if ($index === null) {
|
||||
$this->logger->debug('No changelog entry for version ' . $version . ' found', ['file_path' => $path]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$offsetChangelogEntry = $matches[0][$index][1];
|
||||
// Length of the changelog entry (offset of next match - own offset) or null if the whole rest should be considered
|
||||
$lengthChangelogEntry = $index < ($result - 1) ? ($matches[0][$index + 1][1] - $offsetChangelogEntry) : null;
|
||||
return substr($content, $offsetChangelogEntry, $lengthChangelogEntry);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
* @author Joas Schilling <coding@schilljs.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\UpdateNotification\Notification;
|
||||
|
||||
use OCA\UpdateNotification\AppInfo\Application;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserManager;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Notification\IAction;
|
||||
use OCP\Notification\IManager as INotificationManager;
|
||||
use OCP\Notification\INotification;
|
||||
use OCP\Notification\INotifier;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AppUpdateNotifier implements INotifier {
|
||||
|
||||
public function __construct(
|
||||
private IFactory $l10nFactory,
|
||||
private INotificationManager $notificationManager,
|
||||
private IUserManager $userManager,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IAppManager $appManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return 'updatenotification_app_updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Human readable name describing the notifier
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l10nFactory->get(Application::APP_NAME)->t('App updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param INotification $notification
|
||||
* @param string $languageCode The code of the language that should be used to prepare the notification
|
||||
* @return INotification
|
||||
* @throws \InvalidArgumentException When the notification was not prepared by a notifier
|
||||
*/
|
||||
public function prepare(INotification $notification, string $languageCode): INotification {
|
||||
if ($notification->getApp() !== Application::APP_NAME) {
|
||||
throw new \InvalidArgumentException('Unknown app');
|
||||
}
|
||||
|
||||
if ($notification->getSubject() !== 'app_updated') {
|
||||
throw new \InvalidArgumentException('Unknown subject');
|
||||
}
|
||||
|
||||
$appId = $notification->getSubjectParameters()[0];
|
||||
$appInfo = $this->appManager->getAppInfo($appId, lang:$languageCode);
|
||||
if ($appInfo === null) {
|
||||
throw new \InvalidArgumentException('App info not found');
|
||||
}
|
||||
|
||||
// Prepare translation factory for requested language
|
||||
$l = $this->l10nFactory->get(Application::APP_NAME, $languageCode);
|
||||
|
||||
// See if we can find the app icon - if not fall back to default icon
|
||||
$possibleIcons = [$appId . '-dark.svg', 'app-dark.svg', $appId . '.svg', 'app.svg'];
|
||||
$icon = null;
|
||||
foreach ($possibleIcons as $iconName) {
|
||||
try {
|
||||
$icon = $this->urlGenerator->imagePath($appId, $iconName);
|
||||
} catch (\RuntimeException $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if ($icon === null) {
|
||||
$icon = $this->urlGenerator->imagePath('core', 'default-app-icon');
|
||||
}
|
||||
|
||||
$action = $notification->createAction();
|
||||
$action
|
||||
->setLabel($l->t('See what\'s new'))
|
||||
->setParsedLabel($l->t('See what\'s new'))
|
||||
->setLink($this->urlGenerator->linkToRouteAbsolute('updatenotification.Changelog.showChangelog', ['app' => $appId, 'version' => $this->appManager->getAppVersion($appId)]), IAction::TYPE_WEB);
|
||||
|
||||
$notification
|
||||
->setIcon($this->urlGenerator->getAbsoluteURL($icon))
|
||||
->addParsedAction($action)
|
||||
->setRichSubject(
|
||||
$l->t('{app} updated to version {version}'),
|
||||
[
|
||||
'app' => [
|
||||
'type' => 'app',
|
||||
'id' => $appId,
|
||||
'name' => $appInfo['name'],
|
||||
],
|
||||
'version' => [
|
||||
'type' => 'highlight',
|
||||
'id' => $appId,
|
||||
'name' => $appInfo['version'],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $notification;
|
||||
}
|
||||
}
|
|
@ -114,6 +114,10 @@ class Notifier implements INotifier {
|
|||
throw new \InvalidArgumentException('Unknown app id');
|
||||
}
|
||||
|
||||
if ($notification->getSubject() !== 'update_available' && $notification->getSubject() !== 'connection_error') {
|
||||
throw new \InvalidArgumentException('Unknown subject');
|
||||
}
|
||||
|
||||
$l = $this->l10NFactory->get('updatenotification', $languageCode);
|
||||
if ($notification->getSubject() === 'connection_error') {
|
||||
$errors = (int) $this->config->getAppValue('updatenotification', 'update_check_errors', '0');
|
||||
|
@ -124,40 +128,42 @@ class Notifier implements INotifier {
|
|||
|
||||
$notification->setParsedSubject($l->t('The update server could not be reached since %d days to check for new updates.', [$errors]))
|
||||
->setParsedMessage($l->t('Please check the Nextcloud and server log files for errors.'));
|
||||
} elseif ($notification->getObjectType() === 'core') {
|
||||
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
|
||||
} else {
|
||||
if ($notification->getObjectType() === 'core') {
|
||||
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
|
||||
|
||||
$parameters = $notification->getSubjectParameters();
|
||||
$notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
|
||||
->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
|
||||
'serverAndVersion' => [
|
||||
'type' => 'highlight',
|
||||
$parameters = $notification->getSubjectParameters();
|
||||
$notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
|
||||
->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
|
||||
'serverAndVersion' => [
|
||||
'type' => 'highlight',
|
||||
'id' => $notification->getObjectType(),
|
||||
'name' => $parameters['version'],
|
||||
]
|
||||
]);
|
||||
|
||||
if ($this->isAdmin()) {
|
||||
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
|
||||
}
|
||||
} else {
|
||||
$appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
|
||||
$appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
|
||||
|
||||
if (isset($this->appVersions[$notification->getObjectType()])) {
|
||||
$this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
|
||||
}
|
||||
|
||||
$notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
|
||||
'app' => [
|
||||
'type' => 'app',
|
||||
'id' => $notification->getObjectType(),
|
||||
'name' => $parameters['version'],
|
||||
'name' => $appName,
|
||||
]
|
||||
]);
|
||||
|
||||
if ($this->isAdmin()) {
|
||||
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
|
||||
}
|
||||
} else {
|
||||
$appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
|
||||
$appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
|
||||
|
||||
if (isset($this->appVersions[$notification->getObjectType()])) {
|
||||
$this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
|
||||
}
|
||||
|
||||
$notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
|
||||
'app' => [
|
||||
'type' => 'app',
|
||||
'id' => $notification->getObjectType(),
|
||||
'name' => $appName,
|
||||
]
|
||||
]);
|
||||
|
||||
if ($this->isAdmin()) {
|
||||
$notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
|
||||
if ($this->isAdmin()) {
|
||||
$notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "updatenotification",
|
||||
"version": "0.0.1",
|
||||
"description": "Displays update notifications for Nextcloud and provides the SSO for the updater.",
|
||||
"description": "Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
|
@ -203,6 +203,144 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/updatenotification/api/{apiVersion}/changelog/{appId}": {
|
||||
"get": {
|
||||
"operationId": "api-get-app-changelog-entry",
|
||||
"summary": "Get changelog entry for an app",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "version",
|
||||
"in": "query",
|
||||
"description": "The version to search the changelog entry for (defaults to the latest installed)",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apiVersion",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"v1"
|
||||
],
|
||||
"default": "v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "appId",
|
||||
"in": "path",
|
||||
"description": "App to search changelog entry for",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Changelog entry returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"appName",
|
||||
"content",
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"appName": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No changelog found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<NcDialog content-classes="app-changelog-dialog"
|
||||
:buttons="dialogButtons"
|
||||
:name="t('updatenotification', 'What\'s new in {app} {version}', { app: appName, version: appVersion })"
|
||||
:open="open && markdown !== undefined"
|
||||
size="normal"
|
||||
@update:open="$emit('update:open', $event)">
|
||||
<Markdown class="app-changelog-dialog__text" :markdown="markdown" :min-heading-level="3" />
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
|
||||
import Markdown from './Markdown.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
appId: string
|
||||
version?: string
|
||||
open?: boolean
|
||||
}>(),
|
||||
|
||||
// Default values
|
||||
{
|
||||
open: true,
|
||||
version: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* Event that is called when the "Get started"-button is pressed
|
||||
*/
|
||||
(e: 'dismiss'): void
|
||||
|
||||
(e: 'update:open', v: boolean): void
|
||||
}>()
|
||||
|
||||
const dialogButtons = [
|
||||
{
|
||||
label: t('updatenotification', 'Give feedback'),
|
||||
callback: () => {
|
||||
window.open(`https://apps.nextcloud.com/apps/${props.appId}#comments`, '_blank', 'noreferrer noopener')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('updatenotification', 'Get started'),
|
||||
type: 'primary',
|
||||
callback: () => {
|
||||
emit('dismiss')
|
||||
emit('update:open', false)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const appName = ref(props.appId)
|
||||
const appVersion = ref(props.version ?? '')
|
||||
const markdown = ref<string>('')
|
||||
watchEffect(() => {
|
||||
const url = props.version
|
||||
? generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}?version={version}', { version: props.version, app: props.appId })
|
||||
: generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}', { version: props.version, app: props.appId })
|
||||
|
||||
axios.get(url)
|
||||
.then(({ data }) => {
|
||||
appName.value = data.ocs.data.appName
|
||||
appVersion.value = data.ocs.data.version
|
||||
markdown.value = data.ocs.data.content
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.response?.status === 404) {
|
||||
appName.value = props.appId
|
||||
markdown.value = t('updatenotification', 'No changelog available')
|
||||
} else {
|
||||
console.error('Failed to load changelog entry', error)
|
||||
emit('update:open', false)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.app-changelog-dialog) {
|
||||
min-height: 50vh !important;
|
||||
}
|
||||
|
||||
.app-changelog-dialog__text {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="markdown" v-html="html" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue'
|
||||
import { useMarkdown } from '../composables/useMarkdown'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
markdown: string
|
||||
minHeadingLevel?: 1|2|3|4|5|6
|
||||
}>(),
|
||||
{
|
||||
minHeadingLevel: 2,
|
||||
},
|
||||
)
|
||||
|
||||
const { html } = useMarkdown(toRef(props, 'markdown'), toRef(props, 'minHeadingLevel'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown {
|
||||
:deep {
|
||||
ul {
|
||||
list-style: disc;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: var(--default-font-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,62 @@
|
|||
import type { Ref } from 'vue'
|
||||
|
||||
import { marked } from 'marked'
|
||||
import { computed } from 'vue'
|
||||
import dompurify from 'dompurify'
|
||||
|
||||
export const useMarkdown = (text: Ref<string|undefined|null>, minHeadingLevel: Ref<number|undefined>) => {
|
||||
const minHeading = computed(() => Math.min(Math.max(minHeadingLevel.value ?? 1, 1), 6))
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.link = function(href, title, text) {
|
||||
let out = `<a href="${href}" rel="noreferrer noopener" target="_blank"`
|
||||
if (title) {
|
||||
out += ' title="' + title + '"'
|
||||
}
|
||||
out += '>' + text + '</a>'
|
||||
return out
|
||||
}
|
||||
|
||||
renderer.image = function(href, title, text) {
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
return title ?? ''
|
||||
}
|
||||
|
||||
renderer.heading = (text: string, level: number) => {
|
||||
const headingLevel = Math.max(minHeading.value, level)
|
||||
return `<h${headingLevel}>${text}</h${headingLevel}>`
|
||||
}
|
||||
|
||||
const html = computed(() => dompurify.sanitize(
|
||||
marked((text.value ?? '').trim(), {
|
||||
renderer,
|
||||
gfm: false,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
}),
|
||||
{
|
||||
SAFE_FOR_JQUERY: true,
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'strong',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'em',
|
||||
'del',
|
||||
'blockquote',
|
||||
],
|
||||
},
|
||||
))
|
||||
|
||||
return { html }
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import Vue, { defineAsyncComponent } from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
const navigationEntries = loadState('core', 'apps', {})
|
||||
|
||||
const DialogVue = defineAsyncComponent(() => import('./components/AppChangelogDialog.vue'))
|
||||
|
||||
/**
|
||||
* Show the app changelog dialog
|
||||
*
|
||||
* @param appId The app to show the changelog for
|
||||
* @param version Optional version to show
|
||||
*/
|
||||
function showDialog(appId: string, version?: string) {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let dismissed = false
|
||||
|
||||
const dialog = new Vue({
|
||||
el: element,
|
||||
render: (h) => h(DialogVue, {
|
||||
props: {
|
||||
appId,
|
||||
version,
|
||||
},
|
||||
on: {
|
||||
dismiss: () => { dismissed = true },
|
||||
'update:open': (open: boolean) => {
|
||||
if (!open) {
|
||||
dialog.$destroy?.()
|
||||
resolve(dismissed)
|
||||
|
||||
if (dismissed && appId in navigationEntries) {
|
||||
window.location = navigationEntries[appId].href
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface INotificationActionEvent {
|
||||
cancelAction: boolean
|
||||
notification: Readonly<{
|
||||
notificationId: number
|
||||
objectId: string
|
||||
objectType: string
|
||||
}>
|
||||
action: Readonly<{
|
||||
url: string
|
||||
type: 'WEB'|'GET'|'POST'|'DELETE'
|
||||
}>,
|
||||
}
|
||||
|
||||
subscribe('notifications:action:execute', (event: INotificationActionEvent) => {
|
||||
if (event.notification.objectType === 'app_updated') {
|
||||
event.cancelAction = true
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, app, version, __] = event.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/) ?? []
|
||||
showDialog((app as string|undefined) || (event.notification.objectId as string), version)
|
||||
.then((dismissed) => {
|
||||
if (dismissed) {
|
||||
axios.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: event.notification.notificationId }))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,8 @@
|
|||
import Vue from 'vue'
|
||||
import App from './views/App.vue'
|
||||
|
||||
export default new Vue({
|
||||
name: 'ViewChangelogPage',
|
||||
render: (h) => h(App),
|
||||
el: '#content',
|
||||
})
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<NcContent app-name="updatenotification">
|
||||
<NcAppContent :page-heading="t('updatenotification', 'Changelog for app {app}', { app: appName })">
|
||||
<div class="changelog__wrapper">
|
||||
<h2 class="changelog__heading">
|
||||
{{ t('updatenotification', 'What\'s new in {app} version {version}', { app: appName, version: appVersion }) }}
|
||||
</h2>
|
||||
<Markdown :markdown="markdown" :min-heading-level="3" />
|
||||
</div>
|
||||
</NcAppContent>
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
|
||||
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
|
||||
import Markdown from '../components/Markdown.vue'
|
||||
|
||||
const {
|
||||
appName,
|
||||
appVersion,
|
||||
text: markdown,
|
||||
} = loadState<{ appName: string, appVersion: string, text: string }>('updatenotification', 'changelog')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.changelog__wrapper {
|
||||
max-width: max(50vw,700px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.changelog__heading {
|
||||
font-size: 30px;
|
||||
margin-block: var(--app-navigation-padding, 8px) 1em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
/**
|
||||
* Empty as Vue will take over
|
||||
*/
|
|
@ -114,7 +114,9 @@ module.exports = {
|
|||
settings: path.join(__dirname, 'apps/twofactor_backupcodes/src', 'settings.js'),
|
||||
},
|
||||
updatenotification: {
|
||||
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'init.js'),
|
||||
init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
|
||||
'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),
|
||||
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'updatenotification.js'),
|
||||
},
|
||||
user_status: {
|
||||
menu: path.join(__dirname, 'apps/user_status/src', 'menu.js'),
|
||||
|
|
Loading…
Reference in New Issue