Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a6556e44 | |||
| 2a61e0bd98 | |||
| fc0fc06124 | |||
| 4cb22ad89f | |||
| 6a674f449d | |||
| 67afe77f1c | |||
| 7482a3a0f3 | |||
| 89fe742a83 | |||
| c320f30268 | |||
| 7c0d70dc98 | |||
| 3ad1cfd623 | |||
| 2a3321f1ff |
+5
-4
@@ -17,16 +17,17 @@ node_modules/
|
||||
|
||||
# ignore all apps except core ones
|
||||
/apps*/*
|
||||
!/apps/admin_audit
|
||||
!/apps/appstore
|
||||
!/apps/cloud_federation_api
|
||||
!/apps/comments
|
||||
!/apps/contactsinteraction
|
||||
!/apps/dashboard
|
||||
!/apps/dav
|
||||
!/apps/files
|
||||
!/apps/encryption
|
||||
!/apps/federation
|
||||
!/apps/federatedfilesharing
|
||||
!/apps/sharebymail
|
||||
!/apps/encryption
|
||||
!/apps/files
|
||||
!/apps/files_external
|
||||
!/apps/files_reminders
|
||||
!/apps/files_sharing
|
||||
@@ -38,9 +39,9 @@ node_modules/
|
||||
!/apps/profile
|
||||
!/apps/provisioning_api
|
||||
!/apps/settings
|
||||
!/apps/sharebymail
|
||||
!/apps/systemtags
|
||||
!/apps/testing
|
||||
!/apps/admin_audit
|
||||
!/apps/updatenotification
|
||||
!/apps/theming
|
||||
!/apps/twofactor_backupcodes
|
||||
|
||||
@@ -8,6 +8,12 @@ source_file = translationfiles/templates/admin_audit.pot
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:nextcloud:p:nextcloud:r:appstore]
|
||||
file_filter = translationfiles/<lang>/appstore.po
|
||||
source_file = translationfiles/templates/appstore.pot
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:nextcloud:p:nextcloud:r:cloud_federation_api]
|
||||
file_filter = translationfiles/<lang>/cloud_federation_api.po
|
||||
source_file = translationfiles/templates/cloud_federation_api.pot
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
version = 1
|
||||
SPDX-PackageName = "nextcloud"
|
||||
SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
|
||||
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/server"
|
||||
|
||||
[[annotations]]
|
||||
path = ["tests/fixtures/categories.json", "tests/fixtures/categories-api-response.json"]
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
|
||||
SPDX-License-Identifier = "CC-BY-SA-4.0"
|
||||
|
||||
[[annotations]]
|
||||
path = ["img/app.svg"]
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2018-2024 Google LLC"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>settings</id>
|
||||
<name>Nextcloud Appstore</name>
|
||||
<summary>Nextcloud Appstore</summary>
|
||||
<description>Nextcloud Appstore</description>
|
||||
<version>1.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Nextcloud</author>
|
||||
<namespace>Appstore</namespace>
|
||||
|
||||
<category>customization</category>
|
||||
<bugs>https://github.com/nextcloud/server/issues</bugs>
|
||||
<dependencies>
|
||||
<nextcloud min-version="33" max-version="33"/>
|
||||
</dependencies>
|
||||
</info>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
// autoload.php @generated by Composer
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, $err);
|
||||
} elseif (!headers_sent()) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException($err);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInitAppstore::getLoader();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"config" : {
|
||||
"vendor-dir": ".",
|
||||
"optimize-autoloader": true,
|
||||
"classmap-authoritative": true,
|
||||
"autoloader-suffix": "Appstore"
|
||||
},
|
||||
"autoload" : {
|
||||
"psr-4": {
|
||||
"OCA\\Appstore\\": "../lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d751713988987e9331980363e24189ce",
|
||||
"packages": [],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||
* @internal
|
||||
*/
|
||||
private static $selfDir = null;
|
||||
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private static $installedIsLocalDir;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
|
||||
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||
// all installed packages for example
|
||||
self::$installedIsLocalDir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private static function getSelfDir()
|
||||
{
|
||||
if (self::$selfDir === null) {
|
||||
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||
}
|
||||
|
||||
return self::$selfDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
$copiedLocalDir = false;
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
$selfDir = self::getSelfDir();
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
self::$installedByVendor[$vendorDir] = $required;
|
||||
$installed[] = $required;
|
||||
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||
self::$installed = $required;
|
||||
self::$installedIsLocalDir = true;
|
||||
}
|
||||
}
|
||||
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||
$copiedLocalDir = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array() && !$copiedLocalDir) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'OCA\\Appstore\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\Appstore\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
|
||||
'OCA\\Appstore\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
|
||||
'OCA\\Appstore\\Controller\\DiscoverController' => $baseDir . '/../lib/Controller/DiscoverController.php',
|
||||
'OCA\\Appstore\\Controller\\PageController' => $baseDir . '/../lib/Controller/PageController.php',
|
||||
'OCA\\Appstore\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
'OCA\\Appstore\\' => array($baseDir . '/../lib'),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class ComposerAutoloaderInitAppstore
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
spl_autoload_register(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInitAppstore::getInitializer($loader));
|
||||
|
||||
$loader->setClassMapAuthoritative(true);
|
||||
$loader->register(true);
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerStaticInitAppstore
|
||||
{
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'O' =>
|
||||
array (
|
||||
'OCA\\Appstore\\' => 13,
|
||||
),
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'OCA\\Appstore\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/../lib',
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'OCA\\Appstore\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\Appstore\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
|
||||
'OCA\\Appstore\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
|
||||
'OCA\\Appstore\\Controller\\DiscoverController' => __DIR__ . '/..' . '/../lib/Controller/DiscoverController.php',
|
||||
'OCA\\Appstore\\Controller\\PageController' => __DIR__ . '/..' . '/../lib/Controller/PageController.php',
|
||||
'OCA\\Appstore\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInitAppstore::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInitAppstore::$prefixDirsPsr4;
|
||||
$loader->classMap = ComposerStaticInitAppstore::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"packages": [],
|
||||
"dev": false,
|
||||
"dev-package-names": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => '__root__',
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../',
|
||||
'aliases' => array(),
|
||||
'dev' => false,
|
||||
),
|
||||
'versions' => array(
|
||||
'__root__' => array(
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Appstore\AppInfo;
|
||||
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'appstore';
|
||||
|
||||
/**
|
||||
* @param array $urlParams
|
||||
*/
|
||||
public function __construct(array $urlParams = []) {
|
||||
parent::__construct(self::APP_ID, $urlParams);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\App\AppStore\Fetcher\AppFetcher;
|
||||
use OC\App\AppStore\Fetcher\CategoryFetcher;
|
||||
use OC\App\AppStore\Version\VersionParser;
|
||||
use OC\App\DependencyAnalyzer;
|
||||
use OC\Installer;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCS\OCSException;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Server;
|
||||
use OCP\Support\Subscription\IRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)]
|
||||
class ApiController extends OCSController {
|
||||
|
||||
/** @var array */
|
||||
private $allApps = [];
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private IConfig $config,
|
||||
private IAppConfig $appConfig,
|
||||
private AppManager $appManager,
|
||||
private DependencyAnalyzer $dependencyAnalyzer,
|
||||
private CategoryFetcher $categoryFetcher,
|
||||
private AppFetcher $appFetcher,
|
||||
private IFactory $l10nFactory,
|
||||
private BundleFetcher $bundleFetcher,
|
||||
private Installer $installer,
|
||||
private IRegistry $subscriptionRegistry,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available categories
|
||||
*/
|
||||
#[ApiRoute('GET', '/api/v1/apps/categories')]
|
||||
public function listCategories(): DataResponse {
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
|
||||
$categories = $this->categoryFetcher->get();
|
||||
$categories = array_map(fn ($category) => [
|
||||
'id' => $category['id'],
|
||||
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
|
||||
], $categories);
|
||||
|
||||
return new DataResponse(array_values($categories));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available apps
|
||||
*/
|
||||
#[ApiRoute('GET', '/api/v1/apps')]
|
||||
public function listApps(): DataResponse {
|
||||
$apps = $this->getAllApps();
|
||||
|
||||
$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
|
||||
if (!is_array($ignoreMaxApps)) {
|
||||
$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
|
||||
$ignoreMaxApps = [];
|
||||
}
|
||||
|
||||
// Extend existing app details
|
||||
$apps = array_map(function (array $appData) use ($ignoreMaxApps) {
|
||||
if (isset($appData['appstoreData'])) {
|
||||
$appstoreData = $appData['appstoreData'];
|
||||
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
|
||||
$appData['category'] = $appstoreData['categories'];
|
||||
$appData['releases'] = $appstoreData['releases'];
|
||||
}
|
||||
|
||||
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
|
||||
if ($newVersion) {
|
||||
$appData['update'] = $newVersion;
|
||||
}
|
||||
|
||||
// fix groups to be an array
|
||||
$groups = [];
|
||||
if (is_string($appData['groups'])) {
|
||||
$groups = json_decode($appData['groups']);
|
||||
// ensure 'groups' is an array
|
||||
if (!is_array($groups)) {
|
||||
$groups = [$groups];
|
||||
}
|
||||
}
|
||||
$appData['groups'] = $groups;
|
||||
// analyze dependencies
|
||||
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
|
||||
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
|
||||
$appData['missingDependencies'] = $missing;
|
||||
|
||||
$appData['missingMinNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
|
||||
$appData['missingMaxNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
|
||||
$appData['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData);
|
||||
$appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps());
|
||||
|
||||
return $appData;
|
||||
}, $apps);
|
||||
|
||||
usort($apps, $this->sortApps(...));
|
||||
|
||||
return new DataResponse(array_values($apps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable one apps
|
||||
*
|
||||
* App will be enabled for specific groups only if $groups is defined
|
||||
*
|
||||
* @param string $appId - The app to enable
|
||||
* @param array $groups - The groups to enable the app for
|
||||
* @return DataResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
#[ApiRoute('POST', '/api/v1/apps/enable')]
|
||||
public function enableApp(string $appId, array $groups = []): DataResponse {
|
||||
try {
|
||||
$updateRequired = false;
|
||||
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
|
||||
// Check if app is already downloaded
|
||||
if (!$this->installer->isDownloaded($appId)) {
|
||||
$this->installer->downloadApp($appId);
|
||||
}
|
||||
|
||||
$this->installer->installApp($appId);
|
||||
|
||||
if (count($groups) > 0) {
|
||||
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
|
||||
} else {
|
||||
$this->appManager->enableApp($appId);
|
||||
}
|
||||
$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
|
||||
return new DataResponse(['update_required' => $updateRequired]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('could not enable app', ['exception' => $e]);
|
||||
throw new OCSException('could not enable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an app
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
#[ApiRoute('POST', '/api/v1/apps/disable')]
|
||||
public function disableApp(string $appId): DataResponse {
|
||||
try {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->disableApp($appId);
|
||||
return new DataResponse([]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('could not disable app', ['exception' => $e]);
|
||||
throw new OCSException('could not disable app', Http::STATUS_INTERNAL_SERVER_ERROR, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
#[ApiRoute('POST', '/api/v1/apps/uninstall')]
|
||||
public function uninstallApp(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$result = $this->installer->removeApp($appId);
|
||||
if ($result !== false) {
|
||||
// If this app was force enabled, remove the force-enabled-state
|
||||
$this->appManager->removeOverwriteNextcloudRequirement($appId);
|
||||
$this->appManager->clearAppsCache();
|
||||
return new DataResponse([]);
|
||||
}
|
||||
throw new OCSException('could not remove app', Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an app
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
#[ApiRoute('POST', '/api/v1/apps/update')]
|
||||
public function updateApp(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
|
||||
$this->config->setSystemValue('maintenance', true);
|
||||
try {
|
||||
$result = $this->installer->updateAppstoreApp($appId);
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
if ($result === false) {
|
||||
throw new \Exception('Update failed');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
throw new OCSException('could not update app', Http::STATUS_INTERNAL_SERVER_ERROR, $ex);
|
||||
}
|
||||
|
||||
return new DataResponse([]);
|
||||
}
|
||||
/**
|
||||
* Force enable an app.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
#[ApiRoute('POST', '/api/v1/apps/force')]
|
||||
public function force(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->overwriteNextcloudRequirement($appId);
|
||||
return new DataResponse([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert URL to proxied URL so CSP is no problem
|
||||
*/
|
||||
private function createProxyPreviewUrl(string $url): string {
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
|
||||
}
|
||||
|
||||
private function fetchApps() {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['installed'] = true;
|
||||
|
||||
if (isset($app['screenshot'][0])) {
|
||||
$appScreenshot = $app['screenshot'][0] ?? null;
|
||||
if (is_array($appScreenshot)) {
|
||||
// Screenshot with thumbnail
|
||||
$appScreenshot = $appScreenshot['@value'];
|
||||
}
|
||||
|
||||
$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
|
||||
}
|
||||
$this->allApps[$app['id']] = $app;
|
||||
}
|
||||
|
||||
$apps = $this->getAppsForCategory('');
|
||||
$supportedApps = $this->subscriptionRegistry->delegateGetSupportedApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['appstore'] = true;
|
||||
if (!array_key_exists($app['id'], $this->allApps)) {
|
||||
$this->allApps[$app['id']] = $app;
|
||||
} else {
|
||||
$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
|
||||
}
|
||||
|
||||
if (in_array($app['id'], $supportedApps)) {
|
||||
$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
|
||||
}
|
||||
}
|
||||
|
||||
// add bundle information
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
foreach ($bundle->getAppIdentifiers() as $identifier) {
|
||||
foreach ($this->allApps as &$app) {
|
||||
if ($app['id'] === $identifier) {
|
||||
$app['bundleIds'][] = $bundle->getIdentifier();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getAllApps() {
|
||||
if (empty($this->allApps)) {
|
||||
$this->fetchApps();
|
||||
}
|
||||
return $this->allApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps for a category from the app store
|
||||
*
|
||||
* @param string $requestedCategory
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getAppsForCategory($requestedCategory = ''): array {
|
||||
$versionParser = new VersionParser();
|
||||
$formattedApps = [];
|
||||
$apps = $this->appFetcher->get();
|
||||
foreach ($apps as $app) {
|
||||
// Skip all apps not in the requested category
|
||||
if ($requestedCategory !== '') {
|
||||
$isInCategory = false;
|
||||
foreach ($app['categories'] as $category) {
|
||||
if ($category === $requestedCategory) {
|
||||
$isInCategory = true;
|
||||
}
|
||||
}
|
||||
if (!$isInCategory) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
|
||||
continue;
|
||||
}
|
||||
$nextcloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
|
||||
$nextcloudVersionDependencies = [];
|
||||
if ($nextcloudVersion->getMinimumVersion() !== '') {
|
||||
$nextcloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextcloudVersion->getMinimumVersion();
|
||||
}
|
||||
if ($nextcloudVersion->getMaximumVersion() !== '') {
|
||||
$nextcloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextcloudVersion->getMaximumVersion();
|
||||
}
|
||||
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
|
||||
|
||||
try {
|
||||
$this->appManager->getAppPath($app['id']);
|
||||
$existsLocally = true;
|
||||
} catch (AppPathNotFoundException) {
|
||||
$existsLocally = false;
|
||||
}
|
||||
|
||||
$phpDependencies = [];
|
||||
if ($phpVersion->getMinimumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
|
||||
}
|
||||
if ($phpVersion->getMaximumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
|
||||
}
|
||||
if (isset($app['releases'][0]['minIntSize'])) {
|
||||
$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
|
||||
}
|
||||
$authors = '';
|
||||
foreach ($app['authors'] as $key => $author) {
|
||||
$authors .= $author['name'];
|
||||
if ($key !== count($app['authors']) - 1) {
|
||||
$authors .= ', ';
|
||||
}
|
||||
}
|
||||
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
$enabledValue = $this->appConfig->getValueString($app['id'], 'enabled', 'no');
|
||||
$groups = null;
|
||||
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
|
||||
$groups = $enabledValue;
|
||||
}
|
||||
|
||||
$currentVersion = '';
|
||||
if ($this->appManager->isEnabledForAnyone($app['id'])) {
|
||||
$currentVersion = $this->appManager->getAppVersion($app['id']);
|
||||
} else {
|
||||
$currentVersion = $app['releases'][0]['version'];
|
||||
}
|
||||
|
||||
$formattedApps[] = [
|
||||
'id' => $app['id'],
|
||||
'app_api' => false,
|
||||
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
|
||||
'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
|
||||
'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
|
||||
'license' => $app['releases'][0]['licenses'],
|
||||
'author' => $authors,
|
||||
'shipped' => $this->appManager->isShipped($app['id']),
|
||||
'internal' => in_array($app['id'], $this->appManager->getAlwaysEnabledApps()),
|
||||
'version' => $currentVersion,
|
||||
'types' => [],
|
||||
'documentation' => [
|
||||
'admin' => $app['adminDocs'],
|
||||
'user' => $app['userDocs'],
|
||||
'developer' => $app['developerDocs']
|
||||
],
|
||||
'website' => $app['website'],
|
||||
'bugs' => $app['issueTracker'],
|
||||
'dependencies' => array_merge(
|
||||
$nextcloudVersionDependencies,
|
||||
$phpDependencies
|
||||
),
|
||||
'level' => ($app['isFeatured'] === true) ? 200 : 100,
|
||||
'missingMaxNextcloudVersion' => false,
|
||||
'missingMinNextcloudVersion' => false,
|
||||
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
|
||||
'score' => $app['ratingOverall'],
|
||||
'ratingNumOverall' => $app['ratingNumOverall'],
|
||||
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
|
||||
'removable' => $existsLocally,
|
||||
'active' => $this->appManager->isEnabledForUser($app['id']),
|
||||
'needsDownload' => !$existsLocally,
|
||||
'groups' => $groups,
|
||||
'fromAppStore' => true,
|
||||
'appstoreData' => $app,
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedApps;
|
||||
}
|
||||
|
||||
private function getGroupList(array $groups) {
|
||||
$groupManager = Server::get(IGroupManager::class);
|
||||
$groupsList = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupItem = $groupManager->get($group);
|
||||
if ($groupItem instanceof IGroup) {
|
||||
$groupsList[] = $groupManager->get($group);
|
||||
}
|
||||
}
|
||||
return $groupsList;
|
||||
}
|
||||
|
||||
private function sortApps($a, $b) {
|
||||
$a = (string)$a['name'];
|
||||
$b = (string)$b['name'];
|
||||
if ($a === $b) {
|
||||
return 0;
|
||||
}
|
||||
return ($a < $b) ? -1 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\NotFoundResponse;
|
||||
use OCP\Files\AppData\IAppDataFactory;
|
||||
use OCP\Files\IAppData;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\NotPermittedException;
|
||||
use OCP\Files\SimpleFS\ISimpleFile;
|
||||
use OCP\Files\SimpleFS\ISimpleFolder;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Security\RateLimiting\ILimiter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
class DiscoverController extends Controller {
|
||||
|
||||
private IAppData $appData;
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
IAppDataFactory $appDataFactory,
|
||||
private IClientService $clientService,
|
||||
private AppDiscoverFetcher $discoverFetcher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
$this->appData = $appDataFactory->get(Application::APP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active entries for the app discover section
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute('GET', '/api/v1/discover')]
|
||||
public function getAppDiscoverJSON(): JSONResponse {
|
||||
$data = $this->discoverFetcher->get(true);
|
||||
return new JSONResponse(array_values($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
|
||||
*
|
||||
* @param string $fileName - The image file name
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute('GET', '/api/v1/discover/media')]
|
||||
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): FileDisplayResponse|NotFoundResponse {
|
||||
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
|
||||
$etag = trim($getEtag, '"');
|
||||
|
||||
$folder = null;
|
||||
try {
|
||||
$folder = $this->appData->getFolder('app-discover-cache');
|
||||
$this->cleanUpImageCache($folder, $etag);
|
||||
} catch (\Throwable $e) {
|
||||
$folder = $this->appData->newFolder('app-discover-cache');
|
||||
}
|
||||
|
||||
// Get the current cache folder
|
||||
try {
|
||||
$folder = $folder->getFolder($etag);
|
||||
} catch (NotFoundException $e) {
|
||||
$folder = $folder->newFolder($etag);
|
||||
}
|
||||
|
||||
$info = pathinfo($fileName);
|
||||
$hashName = md5($fileName);
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
// Try to find the file
|
||||
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
|
||||
return str_starts_with($file->getName(), $hashName);
|
||||
});
|
||||
// Get the first entry
|
||||
$file = reset($file);
|
||||
// If not found request from Web
|
||||
if ($file === false) {
|
||||
$user = $session->getUser();
|
||||
// this route is not public thus we can assume a user is logged-in
|
||||
assert($user !== null);
|
||||
// Register a user request to throttle fetching external data
|
||||
// this will prevent using the server for DoS of other systems.
|
||||
$limiter->registerUserRequest(
|
||||
'settings-discover-media',
|
||||
// allow up to 24 media requests per hour
|
||||
// this should be a sane default when a completely new section is loaded
|
||||
// keep in mind browsers request all files from a source-set
|
||||
24,
|
||||
60 * 60,
|
||||
$user,
|
||||
);
|
||||
|
||||
if (!$this->checkCanDownloadMedia($fileName)) {
|
||||
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
|
||||
return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->clientService->newClient();
|
||||
$fileResponse = $client->get($fileName);
|
||||
$contentType = $fileResponse->getHeader('Content-Type');
|
||||
$extension = $info['extension'] ?? '';
|
||||
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
|
||||
return new NotFoundResponse();
|
||||
}
|
||||
} else {
|
||||
// File was found so we can get the content type from the file name
|
||||
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
|
||||
}
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
|
||||
// cache for 7 days
|
||||
$response->cacheFor(604800, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function checkCanDownloadMedia(string $filename): bool {
|
||||
$urlInfo = parse_url($filename);
|
||||
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allowed hosts
|
||||
if ($urlInfo['host'] === 'nextcloud.com') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hosts that need further verification
|
||||
// Github is only allowed if from our organization
|
||||
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
|
||||
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove orphaned folders from the image cache that do not match the current etag
|
||||
* @param ISimpleFolder $folder The folder to clear
|
||||
* @param string $etag The etag (directory name) to keep
|
||||
*/
|
||||
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
|
||||
// Cleanup old cache folders
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
foreach ($allFiles as $dir) {
|
||||
try {
|
||||
if ($dir->getName() !== $etag) {
|
||||
$dir->delete();
|
||||
}
|
||||
} catch (NotPermittedException $e) {
|
||||
// ignore folder for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\Installer;
|
||||
use OCA\AppAPI\Service\ExAppsPageService;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Server;
|
||||
use OCP\Util;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
class PageController extends Controller {
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private IL10N $l10n,
|
||||
private IConfig $config,
|
||||
private Installer $installer,
|
||||
private AppManager $appManager,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IInitialState $initialState,
|
||||
private BundleFetcher $bundleFetcher,
|
||||
private INavigationManager $navigationManager,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
|
||||
*
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute('GET', '/settings/apps', root: '')]
|
||||
#[FrontpageRoute('GET', '/settings/apps/{category}', defaults: ['category' => ''], root: '')]
|
||||
#[FrontpageRoute('GET', '/settings/apps/{category}/{id}', defaults: ['category' => '', 'id' => ''], root: '')]
|
||||
public function viewApps(): TemplateResponse {
|
||||
$this->navigationManager->setActiveEntry('core_apps');
|
||||
|
||||
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
|
||||
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
|
||||
$this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
|
||||
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
|
||||
|
||||
if ($this->appManager->isEnabledForAnyone('app_api')) {
|
||||
try {
|
||||
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
|
||||
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
|
||||
}
|
||||
}
|
||||
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
|
||||
|
||||
$templateResponse = new TemplateResponse(Application::APP_ID, 'empty', ['pageTitle' => $this->l10n->t('App store')]);
|
||||
$templateResponse->setContentSecurityPolicy($policy);
|
||||
|
||||
Util::addStyle(Application::APP_ID, 'main');
|
||||
Util::addScript(Application::APP_ID, 'main');
|
||||
|
||||
return $templateResponse;
|
||||
}
|
||||
|
||||
|
||||
private function getAppsWithUpdates() {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
foreach ($apps as $key => $app) {
|
||||
$newVersion = $this->installer->isUpdateAvailable($app['id']);
|
||||
if ($newVersion === false) {
|
||||
unset($apps[$key]);
|
||||
}
|
||||
}
|
||||
return $apps;
|
||||
}
|
||||
|
||||
private function getBundles() {
|
||||
$result = [];
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
$result[] = [
|
||||
'name' => $bundle->getName(),
|
||||
'id' => $bundle->getIdentifier(),
|
||||
'appIdentifiers' => $bundle->getAppIdentifiers()
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ declare(strict_types=1);
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Settings\Search;
|
||||
namespace OCA\Appstore\Search;
|
||||
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IUser;
|
||||
@@ -24,7 +25,7 @@ class AppSearch implements IProvider {
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return 'settings_apps';
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
@@ -32,7 +33,7 @@ class AppSearch implements IProvider {
|
||||
}
|
||||
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
return $route === 'settings.AppSettings.viewApps' ? -50 : 100;
|
||||
return $route === 'appstore.AppSettings.viewApps' ? -50 : 100;
|
||||
}
|
||||
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "settings-administration",
|
||||
"version": "0.0.1",
|
||||
"description": "Nextcloud settings",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"bearer_auth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {}
|
||||
},
|
||||
"paths": {
|
||||
"/index.php/settings/admin/log/download": {
|
||||
"get": {
|
||||
"operationId": "log_settings-download",
|
||||
"summary": "download logfile",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"log_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Logfile returned",
|
||||
"headers": {
|
||||
"Content-Disposition": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"attachment; filename=\"nextcloud.log\""
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -0,0 +1,709 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "settings-full",
|
||||
"version": "0.0.1",
|
||||
"description": "Nextcloud settings",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"bearer_auth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"DeclarativeForm": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"priority",
|
||||
"section_type",
|
||||
"section_id",
|
||||
"storage_type",
|
||||
"title",
|
||||
"app",
|
||||
"fields"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"section_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"personal"
|
||||
]
|
||||
},
|
||||
"section_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"storage_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"internal",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"doc_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"app": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeclarativeFormField"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeclarativeFormField": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
"default",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text",
|
||||
"password",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"number",
|
||||
"checkbox",
|
||||
"multi-checkbox",
|
||||
"radio",
|
||||
"select",
|
||||
"multi-select"
|
||||
]
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "object"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"sensitive": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status",
|
||||
"statuscode"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"statuscode": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalitems": {
|
||||
"type": "string"
|
||||
},
|
||||
"itemsperpage": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/index.php/settings/admin/log/download": {
|
||||
"get": {
|
||||
"operationId": "log_settings-download",
|
||||
"summary": "download logfile",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"log_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Logfile returned",
|
||||
"headers": {
|
||||
"Content-Disposition": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"attachment; filename=\"nextcloud.log\""
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/settings/api/declarative/value": {
|
||||
"post": {
|
||||
"operationId": "declarative_settings-set-value",
|
||||
"summary": "Sets a declarative settings value",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"app",
|
||||
"formId",
|
||||
"fieldId",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "ID of the app"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string",
|
||||
"description": "ID of the form"
|
||||
},
|
||||
"fieldId": {
|
||||
"type": "string",
|
||||
"description": "ID of the field"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "Value to be saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Value set successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Not logged in or not an admin user",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid arguments to save value",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
|
||||
"post": {
|
||||
"operationId": "declarative_settings-set-sensitive-value",
|
||||
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
|
||||
"description": "This endpoint requires password confirmation",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"app",
|
||||
"formId",
|
||||
"fieldId",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "ID of the app"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string",
|
||||
"description": "ID of the form"
|
||||
},
|
||||
"fieldId": {
|
||||
"type": "string",
|
||||
"description": "ID of the field"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "Value to be saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Value set successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Not logged in or not an admin user",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid arguments to save value",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/settings/api/declarative/forms": {
|
||||
"get": {
|
||||
"operationId": "declarative_settings-get-forms",
|
||||
"summary": "Gets all declarative forms with the values prefilled.",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Forms returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeclarativeForm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -0,0 +1,632 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "settings",
|
||||
"version": "0.0.1",
|
||||
"description": "Nextcloud settings",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"bearer_auth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"DeclarativeForm": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"priority",
|
||||
"section_type",
|
||||
"section_id",
|
||||
"storage_type",
|
||||
"title",
|
||||
"app",
|
||||
"fields"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"section_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"personal"
|
||||
]
|
||||
},
|
||||
"section_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"storage_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"internal",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"doc_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"app": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeclarativeFormField"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeclarativeFormField": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
"default",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text",
|
||||
"password",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"number",
|
||||
"checkbox",
|
||||
"multi-checkbox",
|
||||
"radio",
|
||||
"select",
|
||||
"multi-select"
|
||||
]
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "object"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"sensitive": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status",
|
||||
"statuscode"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"statuscode": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalitems": {
|
||||
"type": "string"
|
||||
},
|
||||
"itemsperpage": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/ocs/v2.php/settings/api/declarative/value": {
|
||||
"post": {
|
||||
"operationId": "declarative_settings-set-value",
|
||||
"summary": "Sets a declarative settings value",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"app",
|
||||
"formId",
|
||||
"fieldId",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "ID of the app"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string",
|
||||
"description": "ID of the form"
|
||||
},
|
||||
"fieldId": {
|
||||
"type": "string",
|
||||
"description": "ID of the field"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "Value to be saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Value set successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Not logged in or not an admin user",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid arguments to save value",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
|
||||
"post": {
|
||||
"operationId": "declarative_settings-set-sensitive-value",
|
||||
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
|
||||
"description": "This endpoint requires password confirmation",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"app",
|
||||
"formId",
|
||||
"fieldId",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "ID of the app"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string",
|
||||
"description": "ID of the form"
|
||||
},
|
||||
"fieldId": {
|
||||
"type": "string",
|
||||
"description": "ID of the field"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "Value to be saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Value set successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Not logged in or not an admin user",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid arguments to save value",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/settings/api/declarative/forms": {
|
||||
"get": {
|
||||
"operationId": "declarative_settings-get-forms",
|
||||
"summary": "Gets all declarative forms with the values prefilled.",
|
||||
"tags": [
|
||||
"declarative_settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"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": "Forms returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeclarativeForm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import AppstoreNavigation from './views/AppstoreNavigation.vue'
|
||||
import AppstoreSidebar from './views/AppstoreSidebar.vue'
|
||||
import { APPSTORE_CATEGORY_NAMES } from './constants.ts'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const currentCategory = computed(() => {
|
||||
if (route.params.category) {
|
||||
return [route.params.category].flat()[0]!
|
||||
}
|
||||
if (route.name === 'apps-bundles') {
|
||||
return 'bundles'
|
||||
}
|
||||
return 'discover'
|
||||
})
|
||||
const heading = computed(() => APPSTORE_CATEGORY_NAMES[currentCategory.value] ?? currentCategory.value)
|
||||
const pageTitle = computed(() => `${heading.value} - ${t('appstore', 'App store')}`)
|
||||
|
||||
const showSidebar = computed(() => !!route.params.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcContent appName="appstore">
|
||||
<AppstoreNavigation />
|
||||
<NcAppContent
|
||||
:class="$style.appstoreApp__content"
|
||||
:pageHeading="t('appstore', 'App store')"
|
||||
:pageTitle>
|
||||
<h2 v-if="heading" :class="$style.appstoreApp__heading">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<router-view />
|
||||
</NcAppContent>
|
||||
<AppstoreSidebar v-if="showSidebar" />
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreApp__content {
|
||||
padding-inline-end: var(--body-container-margin);
|
||||
}
|
||||
|
||||
.appstoreApp__heading {
|
||||
margin-block-start: var(--app-navigation-padding);
|
||||
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
|
||||
min-height: var(--default-clickable-area);
|
||||
line-height: var(--default-clickable-area);
|
||||
vertical-align: center;
|
||||
}
|
||||
</style>
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper for localized values
|
||||
*/
|
||||
export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
|
||||
|
||||
export interface IAppDiscoverElement {
|
||||
/**
|
||||
* Type of the element
|
||||
*/
|
||||
type: typeof APP_DISCOVER_KNOWN_TYPES[number]
|
||||
|
||||
/**
|
||||
* Identifier for this element
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* Order of this element to pin elements (smaller = shown on top)
|
||||
*/
|
||||
order?: number
|
||||
|
||||
/**
|
||||
* Optional, localized, headline for the element
|
||||
*/
|
||||
headline?: ILocalizedValue<string>
|
||||
|
||||
/**
|
||||
* Optional link target for the element
|
||||
*/
|
||||
link?: string
|
||||
|
||||
/**
|
||||
* Optional date when this element will get valid (only show since then)
|
||||
*/
|
||||
date?: number
|
||||
|
||||
/**
|
||||
* Optional date when this element will be invalid (only show until then)
|
||||
*/
|
||||
expiryDate?: number
|
||||
}
|
||||
|
||||
/** Wrapper for media source and MIME type */
|
||||
type MediaSource = { src: string, mime: string }
|
||||
|
||||
/**
|
||||
* Media content type for posts
|
||||
*/
|
||||
interface IAppDiscoverMediaContent {
|
||||
/**
|
||||
* The media source to show - either one or a list of sources with their MIME type for fallback options
|
||||
*/
|
||||
src: MediaSource | MediaSource[]
|
||||
|
||||
/**
|
||||
* Alternative text for the media
|
||||
*/
|
||||
alt: string
|
||||
|
||||
/**
|
||||
* Optional link target for the media (e.g. to the full video)
|
||||
*/
|
||||
link?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for post media
|
||||
*/
|
||||
interface IAppDiscoverMedia {
|
||||
/**
|
||||
* The alignment of the media element
|
||||
*/
|
||||
alignment?: 'start' | 'end' | 'center'
|
||||
|
||||
/**
|
||||
* The (localized) content
|
||||
*/
|
||||
content: ILocalizedValue<IAppDiscoverMediaContent>
|
||||
}
|
||||
|
||||
/**
|
||||
* An app element only used for the showcase type
|
||||
*/
|
||||
export interface IAppDiscoverApp {
|
||||
/** The App ID */
|
||||
type: 'app'
|
||||
appId: string
|
||||
}
|
||||
|
||||
export interface IAppDiscoverPost extends IAppDiscoverElement {
|
||||
type: 'post'
|
||||
text?: ILocalizedValue<string>
|
||||
media?: IAppDiscoverMedia
|
||||
}
|
||||
|
||||
export interface IAppDiscoverShowcase extends IAppDiscoverElement {
|
||||
type: 'showcase'
|
||||
content: (IAppDiscoverPost | IAppDiscoverApp)[]
|
||||
}
|
||||
|
||||
export interface IAppDiscoverCarousel extends IAppDiscoverElement {
|
||||
type: 'carousel'
|
||||
text?: ILocalizedValue<string>
|
||||
content: IAppDiscoverPost[]
|
||||
}
|
||||
|
||||
export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
|
||||
+32
-10
@@ -27,7 +27,16 @@ export interface IAppstoreAppRelease {
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAppstoreApp {
|
||||
export interface IAppstoreAppData extends Record<string, unknown> {
|
||||
ratingOverall: number
|
||||
ratingNumOverall: number
|
||||
ratingRecent: number
|
||||
ratingNumRecent: number
|
||||
|
||||
releases: IAppstoreAppRelease[]
|
||||
}
|
||||
|
||||
export interface IAppstoreAppResponse {
|
||||
id: string
|
||||
name: string
|
||||
summary: string
|
||||
@@ -38,24 +47,36 @@ export interface IAppstoreApp {
|
||||
version: string
|
||||
category: string | string[]
|
||||
|
||||
preview?: string
|
||||
icon?: string
|
||||
screenshot?: string
|
||||
|
||||
app_api: boolean
|
||||
/**
|
||||
* Groups this app is limited to.
|
||||
* (only available if app is already installed)
|
||||
*/
|
||||
groups?: string[]
|
||||
|
||||
score: number
|
||||
ratingNumThresholdReached: boolean
|
||||
|
||||
app_api: false
|
||||
active: boolean
|
||||
internal: boolean
|
||||
removable: boolean
|
||||
installed: boolean
|
||||
canInstall: boolean
|
||||
canUnInstall: boolean
|
||||
isCompatible: boolean
|
||||
needsDownload: boolean
|
||||
missingDependencies?: string[]
|
||||
update?: string
|
||||
|
||||
appstoreData: Record<string, never>
|
||||
appstoreData?: IAppstoreAppData
|
||||
releases?: IAppstoreAppRelease[]
|
||||
}
|
||||
|
||||
export interface IAppstoreApp extends IAppstoreAppResponse {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface IComputeDevice {
|
||||
id: string
|
||||
label: string
|
||||
@@ -81,10 +102,10 @@ export interface IDeployDaemon {
|
||||
export interface IExAppStatus {
|
||||
action: string
|
||||
deploy: number
|
||||
deploy_start_time: number
|
||||
error: string
|
||||
deploy_start_time?: number
|
||||
error?: string
|
||||
init: number
|
||||
init_start_time: number
|
||||
init_start_time?: number
|
||||
type: string
|
||||
}
|
||||
|
||||
@@ -111,8 +132,9 @@ export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
|
||||
}
|
||||
|
||||
export interface IAppstoreExApp extends IAppstoreApp {
|
||||
app_api: true
|
||||
daemon: IDeployDaemon | null | undefined
|
||||
status: IExAppStatus | Record<string, never>
|
||||
error: string
|
||||
error?: string
|
||||
releases: IAppstoreExAppRelease[]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { app, noFallback, size = 20 } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
noFallback?: boolean
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const isSvg = computed(() => app.icon?.endsWith('.svg'))
|
||||
const svgIcon = ref<string>('')
|
||||
watch(() => app.icon, async () => {
|
||||
svgIcon.value = ''
|
||||
if (app.icon?.endsWith('.svg')) {
|
||||
const response = await fetch(app.icon)
|
||||
if (response.ok) {
|
||||
svgIcon.value = await response.text()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="$style.appIcon">
|
||||
<NcIconSvgWrapper
|
||||
v-if="svgIcon"
|
||||
:size
|
||||
:svg="svgIcon" />
|
||||
<img
|
||||
v-else-if="app.icon && !isSvg"
|
||||
:class="$style.appIcon__image"
|
||||
alt=""
|
||||
:src="app.icon"
|
||||
:height="size"
|
||||
:width="size">
|
||||
<NcIconSvgWrapper
|
||||
v-else-if="!noFallback"
|
||||
:path="mdiCogOutline"
|
||||
:size />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appIcon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appImage__image {
|
||||
filter: var(--invert-if-dark);
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const props = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const isError = ref(false)
|
||||
const isLoading = ref(true)
|
||||
watchEffect(() => {
|
||||
if (props.app.screenshot) {
|
||||
isError.value = false
|
||||
isLoading.value = true
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
image.onerror = () => {
|
||||
isError.value = true
|
||||
isLoading.value = false
|
||||
}
|
||||
image.src = props.app.screenshot
|
||||
} else {
|
||||
isLoading.value = false
|
||||
isError.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appImage">
|
||||
<NcIconSvgWrapper
|
||||
v-if="isError || !props.app.screenshot"
|
||||
:size="80"
|
||||
:path="mdiCogOutline" />
|
||||
|
||||
<NcLoadingIcon v-else-if="isLoading" :size="80" />
|
||||
|
||||
<img :class="$style.appImage__image" :src="props.app.screenshot" alt="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appImage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appImage__image {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
+25
-23
@@ -2,19 +2,9 @@
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<span
|
||||
v-if="isSupported || isFeatured"
|
||||
class="app-level-badge"
|
||||
:class="{ 'app-level-badge--supported': isSupported }"
|
||||
:title="badgeTitle">
|
||||
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiCheck, mdiStarShootingOutline } from '@mdi/js'
|
||||
import { mdiStar, mdiStarShootingOutline } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
@@ -28,15 +18,27 @@ const props = defineProps<{
|
||||
|
||||
const isSupported = computed(() => props.level === 300)
|
||||
const isFeatured = computed(() => props.level === 200)
|
||||
const badgeIcon = computed(() => isSupported.value ? mdiStarShootingOutline : mdiCheck)
|
||||
const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured'))
|
||||
const badgeIcon = computed(() => isSupported.value
|
||||
? mdiStarShootingOutline
|
||||
: mdiStar)
|
||||
const badgeText = computed(() => isSupported.value ? t('appstore', 'Supported') : t('appstore', 'Featured'))
|
||||
const badgeTitle = computed(() => isSupported.value
|
||||
? t('settings', 'This app is supported via your current Nextcloud subscription.')
|
||||
: t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
|
||||
? t('appstore', 'This app is supported via your current Nextcloud subscription.')
|
||||
: t('appstore', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-level-badge {
|
||||
<template>
|
||||
<span
|
||||
v-if="isSupported || isFeatured"
|
||||
:class="[ $style.appLevelBadge, { [$style.appLevelBadge__supported]: isSupported } ]"
|
||||
:title="badgeTitle">
|
||||
<NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appLevelBadge {
|
||||
color: var(--color-text-maxcontrast);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-text-maxcontrast);
|
||||
@@ -44,14 +46,14 @@ const badgeTitle = computed(() => isSupported.value
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
gap: var(--default-grid-baseline);
|
||||
padding: 3px 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&--supported {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-border-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
.appLevelBadge__supported {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-border-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* This component either shows a native link to the installed app or external size
|
||||
* or a router link to the appstore page of the app if not installed
|
||||
*/
|
||||
|
||||
import type { RouterLinkProps } from 'vue-router'
|
||||
import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
href: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const knownRoutes = Object.fromEntries(loadState<INavigationEntry[]>('core', 'apps').map((app) => [app.app ?? app.id, app.href]))
|
||||
|
||||
const routerProps = ref<RouterLinkProps>()
|
||||
const linkProps = ref<Record<string, string>>()
|
||||
|
||||
watchEffect(() => {
|
||||
const match = props.href.match(/^app:(\/\/)?([^/]+)(\/.+)?$/)
|
||||
routerProps.value = undefined
|
||||
linkProps.value = undefined
|
||||
|
||||
// not an app url
|
||||
if (match === null) {
|
||||
linkProps.value = {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const appId = match[2]!
|
||||
// Check if specific route was requested
|
||||
if (match[3]) {
|
||||
// we do no know anything about app internal path so we only allow generic app paths
|
||||
linkProps.value = {
|
||||
href: generateUrl(`/apps/${appId}${match[3]}`),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we know any route for that app we open it
|
||||
if (appId in knownRoutes) {
|
||||
linkProps.value = {
|
||||
href: knownRoutes[appId]!,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to show the app store entry
|
||||
routerProps.value = {
|
||||
to: {
|
||||
name: 'apps-details',
|
||||
params: {
|
||||
category: route.params?.category ?? 'discover',
|
||||
id: appId,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="linkProps" v-bind="linkProps">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else-if="routerProps" v-bind="routerProps">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
+26
-26
@@ -19,36 +19,36 @@
|
||||
}">
|
||||
<template v-if="useListView">
|
||||
<div v-if="showUpdateAll" class="apps-list__toolbar">
|
||||
{{ n('settings', '%n app has an update available', '%n apps have an update available', counter) }}
|
||||
{{ n('appstore', '%n app has an update available', '%n apps have an update available', counter) }}
|
||||
<NcButton
|
||||
v-if="showUpdateAll"
|
||||
id="app-list-update-all"
|
||||
variant="primary"
|
||||
@click="updateAll">
|
||||
{{ n('settings', 'Update', 'Update all', counter) }}
|
||||
{{ n('appstore', 'Update', 'Update all', counter) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div v-if="!showUpdateAll" class="apps-list__toolbar">
|
||||
{{ t('settings', 'All apps are up-to-date.') }}
|
||||
{{ t('appstore', 'All apps are up-to-date.') }}
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="apps-list" tag="table" class="apps-list__list-container">
|
||||
<tr key="app-list-view-header">
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<AppItem
|
||||
@@ -64,30 +64,30 @@
|
||||
class="apps-list__list-container">
|
||||
<tr key="app-list-view-header">
|
||||
<th id="app-table-col-icon">
|
||||
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-name">
|
||||
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-version">
|
||||
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-level">
|
||||
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th id="app-table-col-actions">
|
||||
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<template v-for="bundle in bundles">
|
||||
<tr :key="bundle.id">
|
||||
<template v-for="bundle in bundles" :key="bundle.id">
|
||||
<tr>
|
||||
<th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
|
||||
<div class="apps-list__bundle-heading">
|
||||
<span class="apps-list__bundle-header">
|
||||
{{ bundle.name }}
|
||||
</span>
|
||||
<NcButton variant="secondary" @click="toggleBundle(bundle.id)">
|
||||
{{ t('settings', bundleToggleText(bundle.id)) }}
|
||||
{{ t('appstore', bundleToggleText(bundle.id)) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</th>
|
||||
@@ -115,23 +115,23 @@
|
||||
<div class="apps-list__list-container">
|
||||
<table v-if="search !== '' && searchApps.length > 0" class="apps-list__list-container">
|
||||
<caption class="apps-list__bundle-header">
|
||||
{{ t('settings', 'Results from other categories') }}
|
||||
{{ t('appstore', 'Results from other categories') }}
|
||||
</caption>
|
||||
<tr key="app-list-view-header">
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Icon') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Name') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Version') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Level') }}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
|
||||
<span class="hidden-visually">{{ t('appstore', 'Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
<AppItem
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
<div v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0" id="apps-list-empty" class="emptycontent emptycontent-search">
|
||||
<div id="app-list-empty-icon" class="icon-settings-dark" />
|
||||
<h2>{{ t('settings', 'No apps found for your version') }}</h2>
|
||||
<h2>{{ t('appstore', 'No apps found for your version') }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -157,7 +157,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import AppItem from './AppList/AppItem.vue'
|
||||
import OfficeSuiteSwitcher from './AppList/OfficeSuiteSwitcher.vue'
|
||||
import { getOfficeSuiteById, OFFICE_SUITES } from '../constants/OfficeSuites.js'
|
||||
import logger from '../logger.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import AppManagement from '../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
@@ -314,9 +314,9 @@ export default {
|
||||
bundleToggleText() {
|
||||
return (id) => {
|
||||
if (this.allBundlesEnabled(id)) {
|
||||
return t('settings', 'Disable all')
|
||||
return t('appstore', 'Disable all')
|
||||
}
|
||||
return t('settings', 'Download and enable all')
|
||||
return t('appstore', 'Download and enable all')
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -0,0 +1,108 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<li
|
||||
:class="[$style.appListItem, {
|
||||
[$style.appListItem_selected]: isSelected,
|
||||
}]">
|
||||
<div class="app-image app-image-icon">
|
||||
<div v-if="!app?.app_api && !props.app.preview" class="icon-settings-dark" />
|
||||
<NcIconSvgWrapper
|
||||
v-else-if="app.app_api && !props.app.preview"
|
||||
:path="mdiCogOutline"
|
||||
:size="24"
|
||||
style="min-width: auto; min-height: auto; height: 100%;" />
|
||||
|
||||
<svg
|
||||
v-else-if="app.preview && !app.app_api"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32">
|
||||
<image
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
:xlink:href="app.preview"
|
||||
class="app-icon" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">
|
||||
<router-link
|
||||
class="app-name--link"
|
||||
:to="{
|
||||
name: 'apps-details',
|
||||
params: {
|
||||
category: category,
|
||||
id: app.id,
|
||||
},
|
||||
}"
|
||||
:aria-label="t('appstore', 'Show details for {appName} app', { appName: app.name })">
|
||||
{{ app.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<AppListVersion :app />
|
||||
<div class="app-level">
|
||||
<AppLevelBadge :level="app.level" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppLevelBadge from './AppLevelBadge.vue'
|
||||
import AppListVersion from './AppListVersion.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
app: IAppstoreApp
|
||||
category: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const isSelected = ref(false)
|
||||
watchEffect(() => {
|
||||
isSelected.value = props.app.id === route.params.id
|
||||
})
|
||||
|
||||
const screenshotLoaded = ref(false)
|
||||
watchEffect(() => {
|
||||
if (props.app.screenshot) {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
screenshotLoaded.value = true
|
||||
}
|
||||
image.src = props.app.screenshot
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.appListItem {
|
||||
--app-item-padding: calc(var(--default-grid-baseline) * 2);
|
||||
--app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--app-item-padding);
|
||||
height: var(--app-item-height);
|
||||
}
|
||||
}
|
||||
|
||||
.appListItem:hover {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
|
||||
.appListItem_selected {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp } from '../../apps.d.ts'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appListVersion">
|
||||
<span v-if="app.version">{{ app.version }}</span>
|
||||
<span v-else-if="app.appstoreData?.releases[0]?.version">
|
||||
{{ app.appstoreData.releases[0].version }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appListVersion {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
</style>
|
||||
+10
-12
@@ -53,7 +53,7 @@
|
||||
id: app.id,
|
||||
},
|
||||
}"
|
||||
:aria-label="t('settings', 'Show details for {appName} app', { appName: app.name })">
|
||||
:aria-label="t('appstore', 'Show details for {appName} app', { appName: app.name })">
|
||||
{{ app.name }}
|
||||
</router-link>
|
||||
</component>
|
||||
@@ -92,7 +92,7 @@
|
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall"
|
||||
:title="updateButtonText"
|
||||
@click.stop="update(app.id)">
|
||||
{{ t('settings', 'Update to {update}', { update: app.update }) }}
|
||||
{{ t('appstore', 'Update to {update}', { update: app.update }) }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="app.canUnInstall"
|
||||
@@ -100,7 +100,7 @@
|
||||
variant="tertiary"
|
||||
:disabled="installing || isLoading"
|
||||
@click.stop="remove(app.id)">
|
||||
{{ t('settings', 'Remove') }}
|
||||
{{ t('appstore', 'Remove') }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="app.active"
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
<DaemonSelectionDialog
|
||||
v-if="app?.app_api && showSelectDaemonModal"
|
||||
:show.sync="showSelectDaemonModal"
|
||||
v-model:show="showSelectDaemonModal"
|
||||
:app="app" />
|
||||
</component>
|
||||
</component>
|
||||
@@ -139,13 +139,12 @@
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
|
||||
import SvgFilterMixin from '../SvgFilterMixin.vue'
|
||||
import DaemonSelectionDialog from '../DaemonSelectionDialog/DaemonSelectionDialog.vue'
|
||||
import AppLevelBadge from './AppLevelBadge.vue'
|
||||
import AppScore from './AppScore.vue'
|
||||
import AppManagement from '../../mixins/AppManagement.js'
|
||||
import { useAppApiStore } from '../../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../../store/apps-store.js'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
import { useAppApiStore } from '../../store/exApps.ts'
|
||||
|
||||
export default {
|
||||
name: 'AppItem',
|
||||
@@ -157,7 +156,7 @@ export default {
|
||||
DaemonSelectionDialog,
|
||||
},
|
||||
|
||||
mixins: [AppManagement, SvgFilterMixin],
|
||||
mixins: [AppManagement],
|
||||
props: {
|
||||
app: {
|
||||
type: Object,
|
||||
@@ -281,7 +280,6 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../../../../../core/css/variables.scss' as variables;
|
||||
@use 'sass:math';
|
||||
|
||||
.app-item {
|
||||
@@ -364,7 +362,7 @@ export default {
|
||||
}
|
||||
|
||||
/* Hide actions on a small screen. Click on app opens fill-screen sidebar with the buttons */
|
||||
@media only screen and (max-width: math.div(variables.$breakpoint-mobile, 2)) {
|
||||
@media only screen and (max-width: 512px) {
|
||||
.app-actions {
|
||||
display: none;
|
||||
}
|
||||
@@ -437,7 +435,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: variables.$breakpoint-mobile) {
|
||||
@media only screen and (max-width: 1024px) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
+54
-72
@@ -3,6 +3,53 @@
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
|
||||
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
|
||||
import { OFFICE_SUITES } from '../../constants/OfficeSuites.ts'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
const { installedApps = [] } = defineProps<{
|
||||
installedApps: { id: string, active: boolean }[],
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'suite-selected': [suiteId: string | null]
|
||||
}>()
|
||||
|
||||
const isAllInOne = loadState('settings', 'isAllInOne', false)
|
||||
const selectedSuite = shallowRef<string | null>(getInitialSuite())
|
||||
|
||||
function getInitialSuite() {
|
||||
for (const suite of OFFICE_SUITES) {
|
||||
const app = installedApps.find((a) => a.id === suite.appId)
|
||||
if (app && app.active) {
|
||||
return suite.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function selectSuite(suiteId: string) {
|
||||
if (selectedSuite.value === suiteId) {
|
||||
// already selected — keep selection; use the disable button to clear
|
||||
return
|
||||
}
|
||||
selectedSuite.value = suiteId
|
||||
emit('suite-selected', suiteId)
|
||||
}
|
||||
|
||||
function disableSuites() {
|
||||
if (!selectedSuite.value) {
|
||||
return
|
||||
}
|
||||
selectedSuite.value = null
|
||||
emit('suite-selected', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="office-suite-switcher">
|
||||
<div v-if="isAllInOne" class="office-suite-switcher__aio-message">
|
||||
@@ -13,7 +60,7 @@
|
||||
<p>{{ t('settings', 'Select your preferred office suite. Please note that installing requires manual server setup.') }}</p>
|
||||
<div class="office-suite-cards">
|
||||
<div
|
||||
v-for="suite in officeSuites"
|
||||
v-for="suite in OFFICE_SUITES"
|
||||
:key="suite.id"
|
||||
class="office-suite-card"
|
||||
:class="{
|
||||
@@ -56,71 +103,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
|
||||
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
|
||||
import { OFFICE_SUITES } from '../../constants/OfficeSuites.js'
|
||||
|
||||
export default {
|
||||
name: 'OfficeSuiteSwitcher',
|
||||
|
||||
components: {
|
||||
IconCheckCircle,
|
||||
IconArrowRight,
|
||||
},
|
||||
|
||||
props: {
|
||||
installedApps: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['suite-selected'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
isAllInOne: loadState('settings', 'isAllInOne', false),
|
||||
selectedSuite: this.getInitialSuite(),
|
||||
officeSuites: OFFICE_SUITES,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
t,
|
||||
getInitialSuite() {
|
||||
for (const suite of OFFICE_SUITES) {
|
||||
const app = this.installedApps.find((a) => a.id === suite.appId)
|
||||
if (app && app.active) {
|
||||
return suite.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
selectSuite(suiteId) {
|
||||
if (this.selectedSuite === suiteId) {
|
||||
// already selected — keep selection; use the disable button to clear
|
||||
return
|
||||
}
|
||||
this.selectedSuite = suiteId
|
||||
this.$emit('suite-selected', suiteId)
|
||||
},
|
||||
|
||||
disableSuites() {
|
||||
if (this.selectedSuite === null) {
|
||||
return
|
||||
}
|
||||
this.selectedSuite = null
|
||||
this.$emit('suite-selected', null)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.office-suite-switcher {
|
||||
padding: 20px;
|
||||
@@ -134,13 +116,13 @@ export default {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.office-suite-cards {
|
||||
+1
-1
@@ -53,7 +53,7 @@ export default defineComponent({
|
||||
computed: {
|
||||
title() {
|
||||
const appScore = (this.score * 5).toFixed(1)
|
||||
return t('settings', 'Community rating: {score}/5', { score: appScore })
|
||||
return t('appstore', 'Community rating: {score}/5', { score: appScore })
|
||||
},
|
||||
|
||||
fullStars() {
|
||||
+8
-7
@@ -7,18 +7,18 @@
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('settings', 'Daemon')"
|
||||
:name="t('appstore', 'Daemon')"
|
||||
:order="3">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div class="daemon">
|
||||
<h4>{{ t('settings', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('settings', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('settings', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('settings', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('settings', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('settings', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
<h4>{{ t('appstore', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('appstore', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('appstore', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('appstore', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('appstore', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('appstore', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
@@ -27,6 +27,7 @@
|
||||
import type { IAppstoreExApp } from '../../app-types.ts'
|
||||
|
||||
import { mdiFileChart } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
+35
-32
@@ -7,17 +7,17 @@
|
||||
<NcDialog
|
||||
:open="show"
|
||||
size="normal"
|
||||
:name="t('settings', 'Advanced deploy options')"
|
||||
:name="t('appstore', 'Advanced deploy options')"
|
||||
@update:open="$emit('update:show', $event)">
|
||||
<div class="modal__content">
|
||||
<p class="deploy-option__hint">
|
||||
{{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
{{ configuredDeployOptions === null ? t('appstore', 'Edit ExApp deploy options before installation') : t('appstore', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
<a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl">
|
||||
{{ t('settings', 'Learn more') }}
|
||||
{{ t('appstore', 'Learn more') }}
|
||||
</a>
|
||||
</p>
|
||||
<h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)">
|
||||
{{ t('settings', 'Environment variables') }}
|
||||
{{ t('appstore', 'Environment variables') }}
|
||||
</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<div
|
||||
@@ -34,7 +34,7 @@
|
||||
v-else-if="Object.keys(configuredDeployOptions).length > 0"
|
||||
class="envs">
|
||||
<legend class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container environment variables') }}
|
||||
{{ t('appstore', 'ExApp container environment variables') }}
|
||||
</legend>
|
||||
<NcTextField
|
||||
v-for="(value, key) in configuredDeployOptions.environment_variables"
|
||||
@@ -46,28 +46,28 @@
|
||||
</fieldset>
|
||||
<template v-else>
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'No environment variables defined') }}
|
||||
{{ t('appstore', 'No environment variables defined') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<h3>{{ t('settings', 'Mounts') }}</h3>
|
||||
<h3>{{ t('appstore', 'Mounts') }}</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
{{ t('appstore', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
</p>
|
||||
<NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<NcNoteCard type="info" :text="t('appstore', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<div
|
||||
v-for="mount in deployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Remove mount')"
|
||||
:aria-label="t('appstore', 'Remove mount')"
|
||||
style="margin-top: 6px;"
|
||||
@click="removeMount(mount)">
|
||||
<template #icon>
|
||||
@@ -77,73 +77,73 @@
|
||||
</div>
|
||||
<div v-if="addingMount" class="deploy-option">
|
||||
<h4>
|
||||
{{ t('settings', 'New mount') }}
|
||||
{{ t('appstore', 'New mount') }}
|
||||
</h4>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField
|
||||
ref="newMountHostPath"
|
||||
v-model="newMountPoint.hostPath"
|
||||
:label="t('settings', 'Host path')"
|
||||
:aria-label="t('settings', 'Enter path to host folder')" />
|
||||
:label="t('appstore', 'Host path')"
|
||||
:aria-label="t('appstore', 'Enter path to host folder')" />
|
||||
<NcTextField
|
||||
v-model="newMountPoint.containerPath"
|
||||
:label="t('settings', 'Container path')"
|
||||
:aria-label="t('settings', 'Enter path to container folder')" />
|
||||
:label="t('appstore', 'Container path')"
|
||||
:aria-label="t('appstore', 'Enter path to container folder')" />
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="newMountPoint.readonly"
|
||||
:aria-label="t('settings', 'Toggle read-only mode')">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
:aria-label="t('appstore', 'Toggle read-only mode')">
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-top: 4px;">
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Confirm adding new mount')"
|
||||
:aria-label="t('appstore', 'Confirm adding new mount')"
|
||||
@click="addMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiCheck" />
|
||||
</template>
|
||||
{{ t('settings', 'Confirm') }}
|
||||
{{ t('appstore', 'Confirm') }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Cancel adding mount')"
|
||||
:aria-label="t('appstore', 'Cancel adding mount')"
|
||||
style="margin-left: 4px;"
|
||||
@click="cancelAddMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClose" />
|
||||
</template>
|
||||
{{ t('settings', 'Cancel') }}
|
||||
{{ t('appstore', 'Cancel') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton
|
||||
v-if="!addingMount"
|
||||
:aria-label="t('settings', 'Add mount')"
|
||||
:aria-label="t('appstore', 'Add mount')"
|
||||
style="margin-top: 5px;"
|
||||
@click="startAddingMount">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiPlus" />
|
||||
</template>
|
||||
{{ t('settings', 'Add mount') }}
|
||||
{{ t('appstore', 'Add mount') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else-if="configuredDeployOptions.mounts.length > 0">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container mounts') }}
|
||||
{{ t('appstore', 'ExApp container mounts') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="mount in configuredDeployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" readonly />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" readonly />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly" disabled>
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="deploy-option__hint">
|
||||
{{ t('settings', 'No mounts defined') }}
|
||||
{{ t('appstore', 'No mounts defined') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -165,6 +165,7 @@ import { mdiCheck, mdiClose, mdiDeleteOutline, mdiPlus } from '@mdi/js'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { computed, ref } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
@@ -222,6 +223,8 @@ export default {
|
||||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
environmentVariables,
|
||||
deployOptions,
|
||||
store,
|
||||
@@ -244,7 +247,7 @@ export default {
|
||||
|
||||
addingPortBinding: false,
|
||||
configuredDeployOptions: null,
|
||||
deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null),
|
||||
deployOptionsDocsUrl: loadState('appstore', 'deployOptionsDocsUrl', null),
|
||||
}
|
||||
},
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="desc"
|
||||
:name="t('settings', 'Description')"
|
||||
:name="t('appstore', 'Description')"
|
||||
:order="0">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextShort" />
|
||||
@@ -24,7 +24,7 @@ import { mdiTextShort } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import Markdown from '../Markdown.vue'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp
|
||||
+33
-33
@@ -6,7 +6,7 @@
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="details"
|
||||
:name="t('settings', 'Details')"
|
||||
:name="t('appstore', 'Details')"
|
||||
:order="1">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
|
||||
@@ -21,15 +21,15 @@
|
||||
:value="app.id"
|
||||
class="groups-enable__checkbox checkbox"
|
||||
@change="setGroupLimit">
|
||||
<label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
|
||||
<label :for="`groups_enable_${app.id}`">{{ t('appstore', 'Limit to groups') }}</label>
|
||||
<input
|
||||
type="hidden"
|
||||
class="group_select"
|
||||
:title="t('settings', 'All')"
|
||||
:title="t('appstore', 'All')"
|
||||
value="">
|
||||
<br>
|
||||
<label for="limitToGroups">
|
||||
<span>{{ t('settings', 'Limit app usage to groups') }}</span>
|
||||
<span>{{ t('appstore', 'Limit app usage to groups') }}</span>
|
||||
</label>
|
||||
<NcSelect
|
||||
v-if="isLimitedToGroups(app)"
|
||||
@@ -43,7 +43,7 @@
|
||||
@option:selected="addGroupLimitation"
|
||||
@option:deselected="removeGroupLimitation"
|
||||
@search="asyncFindGroup">
|
||||
<span slot="noResult">{{ t('settings', 'No results') }}</span>
|
||||
<span slot="noResult">{{ t('appstore', 'No results') }}</span>
|
||||
</NcSelect>
|
||||
</div>
|
||||
<div class="app-details__actions-manage">
|
||||
@@ -51,14 +51,14 @@
|
||||
v-if="app.update"
|
||||
class="update primary"
|
||||
type="button"
|
||||
:value="t('settings', 'Update to {version}', { version: app.update })"
|
||||
:value="t('appstore', 'Update to {version}', { version: app.update })"
|
||||
:disabled="installing || isLoading || isManualInstall"
|
||||
@click="update(app.id)">
|
||||
<input
|
||||
v-if="app.canUnInstall"
|
||||
class="uninstall"
|
||||
type="button"
|
||||
:value="t('settings', 'Remove')"
|
||||
:value="t('appstore', 'Remove')"
|
||||
:disabled="installing || isLoading"
|
||||
@click="remove(app.id, removeData)">
|
||||
<input
|
||||
@@ -88,36 +88,36 @@
|
||||
@click="forceEnable(app.id)">
|
||||
<NcButton
|
||||
v-if="app?.app_api && (app.canInstall || app.isCompatible)"
|
||||
:aria-label="t('settings', 'Advanced deploy options')"
|
||||
:aria-label="t('appstore', 'Advanced deploy options')"
|
||||
variant="secondary"
|
||||
@click="() => showDeployOptionsModal = true">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiToyBrickPlusOutline" />
|
||||
</template>
|
||||
{{ t('settings', 'Deploy options') }}
|
||||
{{ t('appstore', 'Deploy options') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<p v-if="!defaultDeployDaemonAccessible" class="warning">
|
||||
{{ t('settings', 'Default Deploy daemon is not accessible') }}
|
||||
{{ t('appstore', 'Default Deploy daemon is not accessible') }}
|
||||
</p>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-if="app.canUnInstall"
|
||||
:model-value="removeData"
|
||||
:disabled="installing || isLoading || !defaultDeployDaemonAccessible"
|
||||
@update:modelValue="toggleRemoveData">
|
||||
{{ t('settings', 'Delete data on remove') }}
|
||||
{{ t('appstore', 'Delete data on remove') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<ul class="app-details__dependencies">
|
||||
<li v-if="app.missingMinOwnCloudVersion">
|
||||
{{ t('settings', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</li>
|
||||
<li v-if="app.missingMaxOwnCloudVersion">
|
||||
{{ t('settings', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</li>
|
||||
<li v-if="!app.canInstall">
|
||||
{{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
|
||||
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
|
||||
<ul class="missing-dependencies">
|
||||
<li v-for="(dep, index) in app.missingDependencies" :key="index">
|
||||
{{ dep }}
|
||||
@@ -128,14 +128,14 @@
|
||||
|
||||
<div v-if="lastModified && !app.shipped" class="app-details__section">
|
||||
<h4>
|
||||
{{ t('settings', 'Latest updated') }}
|
||||
{{ t('appstore', 'Latest updated') }}
|
||||
</h4>
|
||||
<NcDateTime :timestamp="lastModified" />
|
||||
</div>
|
||||
|
||||
<div class="app-details__section">
|
||||
<h4>
|
||||
{{ t('settings', 'Author') }}
|
||||
{{ t('appstore', 'Author') }}
|
||||
</h4>
|
||||
<p class="app-details__authors">
|
||||
{{ appAuthors }}
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
<div class="app-details__section">
|
||||
<h4>
|
||||
{{ t('settings', 'Categories') }}
|
||||
{{ t('appstore', 'Categories') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ appCategories }}
|
||||
@@ -152,8 +152,8 @@
|
||||
</div>
|
||||
|
||||
<div v-if="externalResources.length > 0" class="app-details__section">
|
||||
<h4>{{ t('settings', 'Resources') }}</h4>
|
||||
<ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')">
|
||||
<h4>{{ t('appstore', 'Resources') }}</h4>
|
||||
<ul class="app-details__documentation" :aria-label="t('appstore', 'Documentation')">
|
||||
<li v-for="resource of externalResources" :key="resource.id">
|
||||
<a
|
||||
class="appslink"
|
||||
@@ -167,13 +167,13 @@
|
||||
</div>
|
||||
|
||||
<div class="app-details__section">
|
||||
<h4>{{ t('settings', 'Interact') }}</h4>
|
||||
<h4>{{ t('appstore', 'Interact') }}</h4>
|
||||
<div class="app-details__interact">
|
||||
<NcButton
|
||||
:disabled="!app.bugs"
|
||||
:href="app.bugs ?? '#'"
|
||||
:aria-label="t('settings', 'Report a bug')"
|
||||
:title="t('settings', 'Report a bug')">
|
||||
:aria-label="t('appstore', 'Report a bug')"
|
||||
:title="t('appstore', 'Report a bug')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiBugOutline" />
|
||||
</template>
|
||||
@@ -181,8 +181,8 @@
|
||||
<NcButton
|
||||
:disabled="!app.bugs"
|
||||
:href="app.bugs ?? '#'"
|
||||
:aria-label="t('settings', 'Request feature')"
|
||||
:title="t('settings', 'Request feature')">
|
||||
:aria-label="t('appstore', 'Request feature')"
|
||||
:title="t('appstore', 'Request feature')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFeatureSearchOutline" />
|
||||
</template>
|
||||
@@ -190,8 +190,8 @@
|
||||
<NcButton
|
||||
v-if="app.appstoreData?.discussion"
|
||||
:href="app.appstoreData.discussion"
|
||||
:aria-label="t('settings', 'Ask questions or discuss')"
|
||||
:title="t('settings', 'Ask questions or discuss')">
|
||||
:aria-label="t('appstore', 'Ask questions or discuss')"
|
||||
:title="t('appstore', 'Ask questions or discuss')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTooltipQuestionOutline" />
|
||||
</template>
|
||||
@@ -199,8 +199,8 @@
|
||||
<NcButton
|
||||
v-if="!app.internal"
|
||||
:href="rateAppUrl"
|
||||
:aria-label="t('settings', 'Rate the app')"
|
||||
:title="t('settings', 'Rate')">
|
||||
:aria-label="t('appstore', 'Rate the app')"
|
||||
:title="t('appstore', 'Rate')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiStar" />
|
||||
</template>
|
||||
@@ -335,14 +335,14 @@ export default {
|
||||
resources.push({
|
||||
id: 'appstore',
|
||||
href: this.appstoreUrl,
|
||||
label: t('settings', 'View in store'),
|
||||
label: t('appstore', 'View in store'),
|
||||
})
|
||||
}
|
||||
if (this.app.website) {
|
||||
resources.push({
|
||||
id: 'website',
|
||||
href: this.app.website,
|
||||
label: t('settings', 'Visit website'),
|
||||
label: t('appstore', 'Visit website'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation) {
|
||||
@@ -350,21 +350,21 @@ export default {
|
||||
resources.push({
|
||||
id: 'doc-user',
|
||||
href: this.app.documentation.user,
|
||||
label: t('settings', 'Usage documentation'),
|
||||
label: t('appstore', 'Usage documentation'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation.admin) {
|
||||
resources.push({
|
||||
id: 'doc-admin',
|
||||
href: this.app.documentation.admin,
|
||||
label: t('settings', 'Admin documentation'),
|
||||
label: t('appstore', 'Admin documentation'),
|
||||
})
|
||||
}
|
||||
if (this.app.documentation.developer) {
|
||||
resources.push({
|
||||
id: 'doc-developer',
|
||||
href: this.app.documentation.developer,
|
||||
label: t('settings', 'Developer documentation'),
|
||||
label: t('appstore', 'Developer documentation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -6,14 +6,14 @@
|
||||
<NcAppSidebarTab
|
||||
v-if="hasChangelog"
|
||||
id="changelog"
|
||||
:name="t('settings', 'Changelog')"
|
||||
:name="t('appstore', 'Changelog')"
|
||||
:order="2">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClockFast" :size="24" />
|
||||
</template>
|
||||
<div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
|
||||
<h2>{{ release.version }}</h2>
|
||||
<Markdown
|
||||
<MarkdownPreview
|
||||
class="app-sidebar-tabs__release-text"
|
||||
:text="createChangelogFromRelease(release)" />
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@ import { getLanguage, translate as t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import Markdown from '../Markdown.vue'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
const props = defineProps<{ app: IAppstoreApp }>()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import AppTableRow from './AppTableRow.vue'
|
||||
|
||||
defineProps<{
|
||||
apps: (IAppstoreApp | IAppstoreExApp)[]
|
||||
}>()
|
||||
|
||||
const tableElement = useTemplateRef('table')
|
||||
const { width: tableWidth } = useElementSize(tableElement)
|
||||
|
||||
const isNarrow = computed(() => tableWidth.value < 768)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table ref="table" :class="$style.appTable">
|
||||
<thead hidden>
|
||||
<tr>
|
||||
<th>{{ t('appstore', 'App name') }}</th>
|
||||
<th>{{ t('appstore', 'Version') }}</th>
|
||||
<th v-if="!isNarrow">
|
||||
{{ t('appstore', 'Support level') }}
|
||||
</th>
|
||||
<th>{{ t('appstore', 'Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AppTableRow
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app
|
||||
:isNarrow />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTable {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiInformationOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionRouter from '@nextcloud/vue/components/NcActionRouter'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppDaemonBadge from '../AppDaemonBadge.vue'
|
||||
import AppIcon from '../AppIcon.vue'
|
||||
import AppLevelBadge from '../AppLevelBadge.vue'
|
||||
import { useActions } from '../../composables/useActions.ts'
|
||||
|
||||
const { app, isNarrow } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
isNarrow?: boolean
|
||||
}>()
|
||||
|
||||
const actions = useActions(() => app)
|
||||
const inlineActions = computed(() => {
|
||||
if (actions.value.length === 1) {
|
||||
return [...actions.value]
|
||||
}
|
||||
if (isNarrow) {
|
||||
return []
|
||||
}
|
||||
return actions.value.slice(0, 1)
|
||||
.filter((action) => action.inline !== false)
|
||||
})
|
||||
const menuActions = computed(() => actions.value.slice(inlineActions.value.length))
|
||||
|
||||
const route = useRoute()
|
||||
const detailsRoute = computed(() => ({
|
||||
name: route.name!,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr :class="$style.appTableRow">
|
||||
<td :class="$style.appTableRow__nameCell">
|
||||
<NcButton
|
||||
alignment="start"
|
||||
:title="t('appstore', 'Show details')"
|
||||
:to="detailsRoute"
|
||||
variant="tertiary-no-background"
|
||||
wide>
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="app.loading" :size="24" />
|
||||
<AppIcon v-else :app :size="24" />
|
||||
</template>
|
||||
{{ app.name }}
|
||||
<span v-if="app.loading" class="hidden-visually">({{ t('appstore', 'is loading…') }})</span>
|
||||
<span class="hidden-visually">({{ t('appstore', 'Show details') }})</span>
|
||||
</NcButton>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="$style.appTableRow__versionCell">{{ app.version }}</span>
|
||||
</td>
|
||||
<td v-if="!isNarrow">
|
||||
<div :class="$style.appTableRow__levelCell">
|
||||
<AppLevelBadge v-if="app.level" :level="app.level" />
|
||||
<AppDaemonBadge v-if="'daemon' in app && app.daemon" :daemon="app.daemon" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.appTableRow__actionsCell">
|
||||
<NcButton
|
||||
v-for="action in inlineActions"
|
||||
:key="action.id"
|
||||
:ariaLabel="isNarrow ? action.label(app) : undefined"
|
||||
:title="isNarrow ? action.label(app) : undefined"
|
||||
:variant="action.variant"
|
||||
@click="action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
<template v-if="!isNarrow" #default>
|
||||
{{ action.label(app) }}
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcActions forceMenu>
|
||||
<NcActionButton
|
||||
v-for="action in menuActions"
|
||||
:key="action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
@click="action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionButton>
|
||||
<NcActionRouter closeAfterClick :to="detailsRoute">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiInformationOutline" />
|
||||
</template>
|
||||
{{ t('appstore', 'Show details') }}
|
||||
</NcActionRouter>
|
||||
</NcActions>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTableRow {
|
||||
height: calc(var(--default-clickable-area) + var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.appTableRow td {
|
||||
padding-block: var(--default-grid-baseline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.appTableRow__nameCell {
|
||||
/* Padding is needed to have proper focus-visible */
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.appTableRow__levelCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--default-grid-baseline)
|
||||
}
|
||||
|
||||
.appTableRow__versionCell {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.appTableRow__actionsCell {
|
||||
display: flex;
|
||||
gap: var(--default-grid-baseline);
|
||||
justify-content: end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreExApp, IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { mdiFormatListBulleted } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import DaemonSelectionDialogList from './DaemonSelectionDialogList.vue'
|
||||
import { useExAppsStore } from '../../store/exApps.ts'
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* The app to enable
|
||||
*/
|
||||
app: IAppstoreExApp
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: [daemon?: IDeployDaemon]
|
||||
}>()
|
||||
|
||||
const store = useExAppsStore()
|
||||
const appApiAdminPage = generateUrl('/settings/admin/app_api')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcDialog
|
||||
:name="t('appstore', 'Choose Deploy Daemon for {appName}', { appName: app.name })"
|
||||
size="normal"
|
||||
@update:open="$event || $emit('close')">
|
||||
<NcEmptyContent
|
||||
v-if="store.dockerDaemons.length > 0"
|
||||
class="daemon-selection-list__empty-content"
|
||||
:name="t('appstore', 'No Deploy daemons configured')"
|
||||
:description="t('appstore', 'Register a custom one or setup from available templates')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFormatListBulleted" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton :href="appApiAdminPage">
|
||||
{{ t('appstore', 'Manage Deploy daemons') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<DaemonSelectionDialogList
|
||||
v-else
|
||||
:app="app"
|
||||
@selected="$emit('close', $event)" />
|
||||
</NcDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import DaemonSelectionDialogListEntry from './DaemonSelectionDialogListEntry.vue'
|
||||
import { useExAppsStore } from '../../store/exApps.ts'
|
||||
|
||||
defineEmits<{
|
||||
selected: [daemon: IDeployDaemon]
|
||||
}>()
|
||||
|
||||
const store = useExAppsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="$style.DaemonSelectionDialogList"
|
||||
:aria-label="t('appstore', 'Registered Deploy daemons list')">
|
||||
<DaemonSelectionDialogListEntry
|
||||
v-for="daemon in store.dockerDaemons"
|
||||
:key="daemon.id"
|
||||
:daemon
|
||||
:isDefault="store.defaultDaemon!.name === daemon.name"
|
||||
@selected="$emit('selected', daemon)" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.DaemonSelectionDialogList {
|
||||
max-height: 350px;
|
||||
overflow-y: scroll;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDeployDaemon } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* The daemon to use
|
||||
*/
|
||||
daemon: IDeployDaemon
|
||||
/**
|
||||
* Whether this daemon is the default one
|
||||
*/
|
||||
isDefault: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selected: []
|
||||
}>()
|
||||
|
||||
const itemTitle = computed(() => `${props.daemon.name} - ${props.daemon.display_name}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcListItem
|
||||
:active="isDefault"
|
||||
:counterNumber="daemon.exAppsCount"
|
||||
counterType="highlighted"
|
||||
:details="isDefault ? t('appstore', 'Default') : ''"
|
||||
forceDisplayActions
|
||||
:name="itemTitle"
|
||||
@click.stop="emit('selected')">
|
||||
<template #subname>
|
||||
{{ daemon.accepts_deploy_id }}
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
+51
-14
@@ -3,15 +3,8 @@
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<AppItem
|
||||
v-if="app"
|
||||
:app="app"
|
||||
category="discover"
|
||||
class="app-discover-app"
|
||||
inline
|
||||
:list-view="false" />
|
||||
<a
|
||||
v-else
|
||||
v-if="!app"
|
||||
class="app-discover-app app-discover-app__skeleton"
|
||||
:href="appStoreLink"
|
||||
target="_blank"
|
||||
@@ -24,14 +17,32 @@
|
||||
<span class="skeleton-element" />
|
||||
<span class="skeleton-element" />
|
||||
</a>
|
||||
|
||||
<article v-else class="app-discover-app">
|
||||
<AppImage class="app-discover-app__image" :app="app" />
|
||||
<div class="app-discover-app__wrapper">
|
||||
<h3 class="app-discover-app__name">
|
||||
<AppLink :href="`app:${app.id}`">
|
||||
{{ app.name }}
|
||||
</AppLink>
|
||||
</h3>
|
||||
<p>{{ app.summary }}</p>
|
||||
<AppScore
|
||||
v-if="app.ratingNumThresholdReached"
|
||||
class="app-discover-app__score"
|
||||
:score="app.score" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverApp } from '../../apps-discover.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppItem from '../AppList/AppItem.vue'
|
||||
import { useAppsStore } from '../../store/apps-store.ts'
|
||||
import AppImage from '../AppImage.vue'
|
||||
import AppLink from '../AppLink.vue'
|
||||
import AppScore from '../AppScore.vue'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: IAppDiscoverApp
|
||||
@@ -40,16 +51,42 @@ const props = defineProps<{
|
||||
const store = useAppsStore()
|
||||
const app = computed(() => store.getAppById(props.modelValue.appId))
|
||||
|
||||
const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
|
||||
const appStoreLink = computed(() => props.modelValue.appId
|
||||
? `https://apps.nextcloud.com/apps/${props.modelValue.appId}`
|
||||
: '#')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-discover-app {
|
||||
width: 100% !important; // full with of the showcase item
|
||||
border-radius: var(--border-radius-element);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100% !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-rounded);
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 96px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-block: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&__score {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(2 * var(--default-grid-baseline));
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
&__skeleton {
|
||||
+38
-70
@@ -3,7 +3,7 @@
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
|
||||
<section :aria-roledescription="t('appstore', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
|
||||
<h3 v-if="headline" :id="headingId">
|
||||
{{ translatedHeadline }}
|
||||
</h3>
|
||||
@@ -12,7 +12,7 @@
|
||||
<NcButton
|
||||
class="app-discover-carousel__button app-discover-carousel__button--previous"
|
||||
variant="tertiary-no-background"
|
||||
:aria-label="t('settings', 'Previous slide')"
|
||||
:aria-label="t('appstore', 'Previous slide')"
|
||||
:disabled="!hasPrevious"
|
||||
@click="currentIndex -= 1">
|
||||
<template #icon>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<Transition :name="transitionName" mode="out-in">
|
||||
<PostType
|
||||
<DiscoverTypePost
|
||||
v-bind="shownElement"
|
||||
:key="shownElement.id ?? currentIndex"
|
||||
:aria-labelledby="`${internalId}-tab-${currentIndex}`"
|
||||
@@ -35,7 +35,7 @@
|
||||
<NcButton
|
||||
class="app-discover-carousel__button app-discover-carousel__button--next"
|
||||
variant="tertiary-no-background"
|
||||
:aria-label="t('settings', 'Next slide')"
|
||||
:aria-label="t('appstore', 'Next slide')"
|
||||
:disabled="!hasNext"
|
||||
@click="currentIndex += 1">
|
||||
<template #icon>
|
||||
@@ -44,12 +44,12 @@
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
|
||||
<div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('appstore', 'Choose slide to display')">
|
||||
<NcButton
|
||||
v-for="index of content.length"
|
||||
:id="`${internalId}-tab-${index}`"
|
||||
:key="index"
|
||||
:aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
|
||||
:aria-label="t('appstore', '{index} of {total}', { index, total: content.length })"
|
||||
:aria-controls="`${internalId}-tabpanel-${index}`"
|
||||
:aria-selected="`${currentIndex === (index - 1)}`"
|
||||
role="tab"
|
||||
@@ -63,85 +63,53 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverCarousel } from '../../apps-discover.d.ts'
|
||||
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, defineComponent, nextTick, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import PostType from './PostType.vue'
|
||||
import DiscoverTypePost from './DiscoverTypePost.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CarouselType',
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
NcIconSvgWrapper,
|
||||
PostType,
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverCarousel['content']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverCarousel['content']>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
const currentIndex = ref(Math.min(1, props.content.length - 1))
|
||||
const shownElement = ref(props.content[currentIndex.value]!)
|
||||
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
|
||||
const hasPrevious = computed(() => currentIndex.value > 0)
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
|
||||
const headingId = computed(() => `${internalId.value}-h`)
|
||||
|
||||
const currentIndex = ref(Math.min(1, props.content.length - 1))
|
||||
const shownElement = ref(props.content[currentIndex.value])
|
||||
const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
|
||||
const hasPrevious = computed(() => currentIndex.value > 0)
|
||||
const transitionName = ref('slide-in')
|
||||
watch(() => currentIndex.value, (o, n) => {
|
||||
if (o < n) {
|
||||
transitionName.value = 'slide-in'
|
||||
} else {
|
||||
transitionName.value = 'slide-out'
|
||||
}
|
||||
|
||||
const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
|
||||
const headingId = computed(() => `${internalId.value}-h`)
|
||||
|
||||
const transitionName = ref('slide-in')
|
||||
watch(() => currentIndex.value, (o, n) => {
|
||||
if (o < n) {
|
||||
transitionName.value = 'slide-in'
|
||||
} else {
|
||||
transitionName.value = 'slide-out'
|
||||
}
|
||||
|
||||
// Wait next tick
|
||||
nextTick(() => {
|
||||
shownElement.value = props.content[currentIndex.value]
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
internalId,
|
||||
headingId,
|
||||
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
currentIndex,
|
||||
shownElement,
|
||||
|
||||
transitionName,
|
||||
|
||||
translatedHeadline,
|
||||
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiCircleOutline,
|
||||
mdiCircleSlice8,
|
||||
}
|
||||
},
|
||||
// Wait next tick
|
||||
nextTick(() => {
|
||||
shownElement.value = props.content[currentIndex.value]!
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
+81
-109
@@ -47,7 +47,7 @@
|
||||
:type="source.mime">
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="generatePrivacyUrl(mediaSources[0].src)"
|
||||
:src="generatePrivacyUrl(mediaSources[0]!.src)"
|
||||
:alt="mediaAlt">
|
||||
</component>
|
||||
<div class="app-discover-post__play-icon-wrapper">
|
||||
@@ -61,137 +61,109 @@
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverPost } from '../../apps-discover.d.ts'
|
||||
|
||||
import { mdiPlayCircleOutline } from '@mdi/js'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { useElementSize, useElementVisibility } from '@vueuse/core'
|
||||
import { computed, defineComponent, ref, watchEffect } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppLink from './AppLink.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
AppLink,
|
||||
NcIconSvgWrapper,
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
text: {
|
||||
type: Object as PropType<IAppDiscoverPost['text']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
text: {
|
||||
type: Object as PropType<IAppDiscoverPost['text']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
media: {
|
||||
type: Object as PropType<IAppDiscoverPost['media']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
domId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
media: {
|
||||
type: Object as PropType<IAppDiscoverPost['media']>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const translatedText = useLocalizedValue(computed(() => props.text))
|
||||
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
|
||||
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
|
||||
domId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
|
||||
/**
|
||||
* Is the media is shown full width
|
||||
*/
|
||||
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
const translatedText = useLocalizedValue(computed(() => props.text))
|
||||
const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
|
||||
|
||||
/**
|
||||
* Link on the media
|
||||
* Fallback to post link to prevent link inside link (which is invalid HTML)
|
||||
*/
|
||||
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
|
||||
const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
|
||||
const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
|
||||
|
||||
const hasPlaybackEnded = ref(false)
|
||||
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
|
||||
const isImage = computed(() => mediaSources.value?.[0]?.mime.startsWith('image/') === true)
|
||||
/**
|
||||
* Is the media is shown full width
|
||||
*/
|
||||
const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
|
||||
|
||||
/**
|
||||
* The content is sized / styles are applied based on the container width
|
||||
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 600)
|
||||
/**
|
||||
* Link on the media
|
||||
* Fallback to post link to prevent link inside link (which is invalid HTML)
|
||||
*/
|
||||
const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
|
||||
|
||||
/**
|
||||
* Generate URL for cached media to prevent user can be tracked
|
||||
*
|
||||
* @param url The URL to resolve
|
||||
*/
|
||||
const generatePrivacyUrl = (url: string) => url.startsWith('/') ? url : generateUrl('/settings/api/apps/media?fileName={fileName}', { fileName: url })
|
||||
const hasPlaybackEnded = ref(false)
|
||||
const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
|
||||
|
||||
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
|
||||
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
|
||||
watchEffect(() => {
|
||||
// Only if media is video
|
||||
if (!isImage.value && mediaElement.value) {
|
||||
const video = mediaElement.value as HTMLVideoElement
|
||||
/**
|
||||
* The content is sized / styles are applied based on the container width
|
||||
* To make it responsive even for inline usage and when opening / closing the sidebar / navigation
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 600)
|
||||
|
||||
if (mediaIsVisible.value) {
|
||||
// Ensure video is muted - otherwise .play() will be blocked by browsers
|
||||
video.muted = true
|
||||
// If visible start playback
|
||||
video.play()
|
||||
} else {
|
||||
// If not visible pause the playback
|
||||
video.pause()
|
||||
// If the animation has ended reset
|
||||
if (video.ended) {
|
||||
video.currentTime = 0
|
||||
hasPlaybackEnded.value = false
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate URL for cached media to prevent user can be tracked
|
||||
*
|
||||
* @param url The URL to resolve
|
||||
*/
|
||||
function generatePrivacyUrl(url: string) {
|
||||
return url.startsWith('/')
|
||||
? url
|
||||
: generateUrl('/apps/appstore/api/v1/discover/media?fileName={fileName}', { fileName: url })
|
||||
}
|
||||
|
||||
const mediaElement = ref<HTMLVideoElement | HTMLPictureElement>()
|
||||
const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
|
||||
watchEffect(() => {
|
||||
// Only if media is video
|
||||
if (!isImage.value && mediaElement.value) {
|
||||
const video = mediaElement.value as HTMLVideoElement
|
||||
|
||||
if (mediaIsVisible.value) {
|
||||
// Ensure video is muted - otherwise .play() will be blocked by browsers
|
||||
video.muted = true
|
||||
// If visible start playback
|
||||
video.play()
|
||||
} else {
|
||||
// If not visible pause the playback
|
||||
video.pause()
|
||||
// If the animation has ended reset
|
||||
if (video.ended) {
|
||||
video.currentTime = 0
|
||||
hasPlaybackEnded.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mdiPlayCircleOutline,
|
||||
|
||||
container,
|
||||
|
||||
translatedText,
|
||||
translatedHeadline,
|
||||
mediaElement,
|
||||
mediaSources,
|
||||
mediaAlt,
|
||||
mediaLink,
|
||||
|
||||
hasPlaybackEnded,
|
||||
showPlayVideo,
|
||||
|
||||
isFullWidth,
|
||||
isSmallWidth,
|
||||
isImage,
|
||||
|
||||
generatePrivacyUrl,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
+26
-47
@@ -16,71 +16,50 @@
|
||||
<ul class="app-discover-showcase__list">
|
||||
<li
|
||||
v-for="(item, index) of content"
|
||||
:key="item.id ?? index"
|
||||
:key="'id' in item ? item.id : index"
|
||||
class="app-discover-showcase__item">
|
||||
<PostType
|
||||
<DiscoverTypePost
|
||||
v-if="item.type === 'post'"
|
||||
v-bind="item"
|
||||
inline />
|
||||
<AppType v-else-if="item.type === 'app'" :model-value="item" />
|
||||
<DiscoverTypeApp v-else-if="item.type === 'app'" :model-value="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverShowcase } from '../../apps-discover.d.ts'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
import AppType from './AppType.vue'
|
||||
import PostType from './PostType.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import DiscoverTypeApp from './DiscoverTypeApp.vue'
|
||||
import DiscoverTypePost from './DiscoverTypePost.vue'
|
||||
import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
|
||||
import { commonAppDiscoverProps } from './common.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ShowcaseType',
|
||||
const props = defineProps({
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
components: {
|
||||
AppType,
|
||||
PostType,
|
||||
},
|
||||
|
||||
props: {
|
||||
...commonAppDiscoverProps,
|
||||
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverShowcase['content']>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* Make the element responsive based on the container width to also handle open navigation or sidebar
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 768)
|
||||
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
container,
|
||||
isSmallWidth,
|
||||
isExtraSmallWidth,
|
||||
translatedHeadline,
|
||||
}
|
||||
/**
|
||||
* The content of the carousel
|
||||
*/
|
||||
content: {
|
||||
type: Array as PropType<IAppDiscoverShowcase['content']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const translatedHeadline = useLocalizedValue(computed(() => props.headline))
|
||||
|
||||
/**
|
||||
* Make the element responsive based on the container width to also handle open navigation or sidebar
|
||||
*/
|
||||
const container = ref<HTMLElement>()
|
||||
const { width: containerWidth } = useElementSize(container)
|
||||
const isSmallWidth = computed(() => containerWidth.value < 768)
|
||||
const isExtraSmallWidth = computed(() => containerWidth.value < 512)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
+2
-2
@@ -3,9 +3,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { PropType } from 'vue'
|
||||
import type { IAppDiscoverElement } from '../../constants/AppDiscoverTypes.ts'
|
||||
import type { IAppDiscoverElement } from '../../apps-discover.d.ts'
|
||||
|
||||
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants/AppDiscoverTypes.ts'
|
||||
import { APP_DISCOVER_KNOWN_TYPES } from '../../constants.ts'
|
||||
|
||||
/**
|
||||
* Common Props for all app discover types
|
||||
@@ -0,0 +1,33 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import MarkdownPreview from './MarkdownPreview.vue'
|
||||
|
||||
describe('MarkdownPreview component', () => {
|
||||
beforeEach(cleanup)
|
||||
|
||||
it('renders', () => {
|
||||
const component = render(MarkdownPreview, {
|
||||
props: {
|
||||
minHeadingLevel: 2,
|
||||
text: `# Heading one
|
||||
This is [a link](http://example.com)!
|
||||
## Heading two
|
||||
> This is a block quote
|
||||
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(component.getByRole('heading', { level: 2, name: 'Heading one' })).toBeTruthy()
|
||||
expect(component.getByRole('heading', { level: 3, name: 'Heading two' })).toBeTruthy()
|
||||
expect(component.getByText('This is a block quote')).toBeInstanceOf(HTMLQuoteElement)
|
||||
expect(component.getByRole('link', { name: 'a link' })).toBeInstanceOf(HTMLAnchorElement)
|
||||
expect(component.getByRole('link', { name: 'a link' }).getAttribute('href')).toBe('http://example.com')
|
||||
expect(() => component.getByRole('img')).toThrow() // its a text
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '../composables/useMarkdown.ts'
|
||||
|
||||
const {
|
||||
text,
|
||||
minHeadingLevel = 1,
|
||||
} = defineProps<{
|
||||
/**
|
||||
* The markdown text to render
|
||||
*/
|
||||
text: string
|
||||
/**
|
||||
* Limit the minimum heading level
|
||||
*/
|
||||
minHeadingLevel?: number
|
||||
}>()
|
||||
|
||||
const renderMarkdown = useMarkdown(() => text, { minHeadingLevel })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="settings-markdown" v-html="renderMarkdown" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-markdown :deep {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
&::after {
|
||||
content: '↗';
|
||||
padding-inline: calc(var(--default-grid-baseline) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1em 1.3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
p code {
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding: .1em .3em;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-inline-start: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ul > li > ul > li {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul > li > ul > li ul li {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-inline-start: 1em;
|
||||
border-inline-start: 4px solid var(--color-primary-element);
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiAlertCircleCheckOutline, mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
import { canDisable, canEnable, canInstall, canUninstall, canUpdate, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
type AppAction = {
|
||||
id: string
|
||||
icon: string
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
|
||||
variant?: 'primary' | 'error' | 'warning'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const AppAction = Object.freeze({
|
||||
INSTALL: {
|
||||
id: 'install',
|
||||
icon: mdiDownload,
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and enable')
|
||||
}
|
||||
return t('appstore', 'Install and enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
ENABLE: {
|
||||
id: 'enable',
|
||||
icon: mdiCheck,
|
||||
variant: 'primary',
|
||||
label: () => t('appstore', 'Enable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
FORCE_ENABLE: {
|
||||
id: 'force-enable',
|
||||
icon: mdiAlertCircleCheckOutline,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Force enable'),
|
||||
variant: 'warning',
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.forceEnableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
DISABLE: {
|
||||
id: 'disable',
|
||||
icon: mdiClose,
|
||||
label: () => t('appstore', 'Disable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.disableApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
REMOVE: {
|
||||
id: 'remove',
|
||||
icon: mdiTrashCanOutline,
|
||||
variant: 'error',
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Remove'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.uninstallApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
UPDATE: {
|
||||
id: 'update',
|
||||
icon: mdiUpdate,
|
||||
variant: 'primary',
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useUpdatesStore()
|
||||
await store.updateApp(app.id)
|
||||
},
|
||||
} as AppAction,
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the available actions for an app
|
||||
*
|
||||
* @param app - The app to get the actions for
|
||||
*/
|
||||
export function useActions(app: MaybeRefOrGetter<IAppstoreApp | IAppstoreExApp>) {
|
||||
return computed(() => {
|
||||
const actions: typeof AppAction[keyof typeof AppAction][] = []
|
||||
if (canUpdate(toValue(app))) {
|
||||
actions.push(AppAction.UPDATE)
|
||||
}
|
||||
|
||||
if (canDisable(toValue(app))) {
|
||||
actions.push(AppAction.DISABLE)
|
||||
}
|
||||
|
||||
if (needForceEnable(toValue(app))) {
|
||||
actions.push(AppAction.FORCE_ENABLE)
|
||||
} else if (canInstall(toValue(app))) {
|
||||
actions.push(AppAction.INSTALL)
|
||||
} else if (canEnable(toValue(app))) {
|
||||
actions.push(AppAction.ENABLE)
|
||||
}
|
||||
|
||||
if (canUninstall(toValue(app))) {
|
||||
actions.push(AppAction.REMOVE)
|
||||
}
|
||||
return actions
|
||||
})
|
||||
}
|
||||
+9
-8
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { IAppstoreApp } from '../app-types.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCog, mdiCogOutline } from '@mdi/js'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
/**
|
||||
* Get the app icon raw SVG for use with `NcIconSvgWrapper` (do never use without sanitizing)
|
||||
@@ -16,7 +17,7 @@ import logger from '../logger.ts'
|
||||
*
|
||||
* @param app The app to get the icon for
|
||||
*/
|
||||
export function useAppIcon(app: Ref<IAppstoreApp>) {
|
||||
export function useAppIcon(app: Ref<IAppstoreApp | IAppstoreExApp | null>) {
|
||||
const appIcon = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,7 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
|
||||
path = mdiCogOutline
|
||||
} else {
|
||||
path = [app.value?.category ?? []].flat()
|
||||
.map((name) => AppstoreCategoryIcons[name])
|
||||
.map((name) => APPSTORE_CATEGORY_ICONS[name])
|
||||
.filter((icon) => !!icon)
|
||||
.at(0)
|
||||
?? (!app.value?.app_api ? mdiCog : mdiCogOutline)
|
||||
@@ -39,13 +40,13 @@ export function useAppIcon(app: Ref<IAppstoreApp>) {
|
||||
|
||||
watchEffect(async () => {
|
||||
// Note: Only variables until the first `await` will be watched!
|
||||
if (!app.value?.preview) {
|
||||
if (!app.value?.icon) {
|
||||
appIcon.value = categoryIcon.value
|
||||
} else {
|
||||
appIcon.value = null
|
||||
// Now try to load the real app icon
|
||||
try {
|
||||
const response = await window.fetch(app.value.preview)
|
||||
const response = await window.fetch(app.value.icon)
|
||||
const blob = await response.blob()
|
||||
const rawSvg = await blob.text()
|
||||
appIcon.value = rawSvg.replaceAll(/fill="#(fff|ffffff)([a-z0-9]{1,2})?"/ig, 'fill="currentColor"')
|
||||
+15
-15
@@ -1,23 +1,13 @@
|
||||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { ILocalizedValue } from '../constants/AppDiscoverTypes.ts'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { ILocalizedValue } from '../apps-discover.d.ts'
|
||||
|
||||
import { getLanguage } from '@nextcloud/l10n'
|
||||
import {
|
||||
type Ref,
|
||||
|
||||
computed,
|
||||
} from 'vue'
|
||||
|
||||
/**
|
||||
* Helper to get the localized value for the current users language
|
||||
*
|
||||
* @param dict The dictionary to get the value from
|
||||
* @param language The language to use
|
||||
*/
|
||||
const getLocalizedValue = <T>(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Get the localized value of the dictionary provided
|
||||
@@ -33,3 +23,13 @@ export function useLocalizedValue<T>(dict: Ref<ILocalizedValue<T | undefined> |
|
||||
|
||||
return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the localized value for the current users language
|
||||
*
|
||||
* @param dict The dictionary to get the value from
|
||||
* @param language The language to use
|
||||
*/
|
||||
function getLocalizedValue<T>(dict: ILocalizedValue<T>, language: string) {
|
||||
return dict[language] ?? dict[language.split('_')[0]!] ?? dict.en ?? null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { expect, test } from 'vitest'
|
||||
import { useMarkdown } from './useMarkdown.ts'
|
||||
|
||||
test('renders links', () => {
|
||||
const rendered = useMarkdown('This is [a link](http://example.com)!')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>This is <a href="http://example.com" rel="noreferrer noopener">a link</a>!</p>\n"')
|
||||
})
|
||||
|
||||
test('removes links with invalid URL', () => {
|
||||
const rendered = useMarkdown('This is [a link](ftp://example.com)!')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>This is !</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>alt text</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images with title', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<p>Title</p>\n"')
|
||||
})
|
||||
|
||||
test('renders images with alt text and title', () => {
|
||||
const rendered = useMarkdown('')
|
||||
expect(rendered.value).toMatchInlineSnapshot(`
|
||||
"<p>alt text</p>\n"
|
||||
`)
|
||||
})
|
||||
|
||||
test('renders block quotes', () => {
|
||||
const rendered = useMarkdown('> This is a block quote')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<blockquote>This is a block quote</blockquote>"')
|
||||
})
|
||||
|
||||
test('renders headings', () => {
|
||||
const rendered = useMarkdown('# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n')
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<h1>level 1</h1><h2>level 2</h2><h3>level 3</h3><h4>level 4</h4><h5>level 5</h5><h6>level 6</h6>"')
|
||||
})
|
||||
|
||||
test('renders headings with minHeadingLevel', () => {
|
||||
const rendered = useMarkdown(
|
||||
'# level 1\n## level 2\n### level 3\n#### level 4\n##### level 5\n###### level 6\n',
|
||||
{ minHeadingLevel: 4 },
|
||||
)
|
||||
expect(rendered.value).toMatchInlineSnapshot('"<h4>level 1</h4><h5>level 2</h5><h6>level 3</h6><h6>level 4</h6><h6>level 5</h6><h6>level 6</h6>"')
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Tokens } from 'marked'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import dompurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
export interface MarkdownOptions {
|
||||
minHeadingLevel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Markdown to HTML
|
||||
*
|
||||
* @param text - The Markdown source
|
||||
* @param options - Markdown options
|
||||
*/
|
||||
export function useMarkdown(text: MaybeRefOrGetter<string>, options?: MarkdownOptions) {
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.blockquote = markedBlockquote
|
||||
renderer.link = markedLink
|
||||
renderer.image = markedImage
|
||||
|
||||
return computed(() => {
|
||||
const minHeading = options?.minHeadingLevel ?? 1
|
||||
renderer.heading = getMarkedHeading(minHeading)
|
||||
const markdown = toValue(text).trim()
|
||||
|
||||
return dompurify.sanitize(
|
||||
marked(markdown, {
|
||||
async: false,
|
||||
renderer,
|
||||
gfm: false,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
}),
|
||||
{
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'strong',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'em',
|
||||
'del',
|
||||
'blockquote',
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom link renderer that only allows http and https links
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.href - The link href
|
||||
* @param ctx.title - The link title
|
||||
* @param ctx.text - The link text
|
||||
*/
|
||||
function markedLink({ href, title, text }: Tokens.Link) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
|
||||
let out = '<a href="' + href + '" rel="noreferrer noopener"'
|
||||
if (title) {
|
||||
out += ' title="' + title + '"'
|
||||
}
|
||||
out += '>' + text + '</a>'
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Only render image alt text or title
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.title - The image title
|
||||
* @param ctx.text - The image alt text
|
||||
*/
|
||||
function markedImage({ title, text }: Tokens.Image): string {
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
return title ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Render block quotes without any special styling
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.text - The blockquote text
|
||||
*/
|
||||
function markedBlockquote({ text }: Tokens.Blockquote): string {
|
||||
return `<blockquote>${text}</blockquote>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a custom heading renderer that clamps heading levels
|
||||
*
|
||||
* @param minHeading - The heading to clamp to
|
||||
*/
|
||||
function getMarkedHeading(minHeading: number) {
|
||||
/**
|
||||
* Custom heading renderer that adjusts heading levels
|
||||
*
|
||||
* @param ctx - The render context
|
||||
* @param ctx.text - The heading text
|
||||
* @param ctx.depth - The heading depth
|
||||
*/
|
||||
return ({ text, depth }: Tokens.Heading): string => {
|
||||
depth = Math.min(6, depth + (minHeading - 1))
|
||||
return `<h${depth}>${text}</h${depth}>`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
mdiAccountMultipleOutline,
|
||||
mdiAccountOutline,
|
||||
mdiArchiveOutline,
|
||||
mdiCheck,
|
||||
mdiClipboardFlowOutline,
|
||||
mdiClose,
|
||||
mdiCogOutline,
|
||||
mdiControllerClassicOutline,
|
||||
mdiCreationOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentEdit,
|
||||
mdiFolder,
|
||||
mdiKeyOutline,
|
||||
mdiMagnify,
|
||||
mdiMonitorEye,
|
||||
mdiMultimedia,
|
||||
mdiOfficeBuildingOutline,
|
||||
mdiOpenInApp,
|
||||
mdiSecurity,
|
||||
mdiStar,
|
||||
mdiStarCircleOutline,
|
||||
mdiStarShootingOutline,
|
||||
mdiTools,
|
||||
mdiViewColumnOutline,
|
||||
} from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
/**
|
||||
* The names of the special appstore sections
|
||||
*/
|
||||
export const APPSTORE_CATEGORY_NAMES = Object.freeze({
|
||||
discover: t('appstore', 'Discover'),
|
||||
installed: t('appstore', 'Your apps'),
|
||||
enabled: t('appstore', 'Active apps'),
|
||||
disabled: t('appstore', 'Disabled apps'),
|
||||
updates: t('appstore', 'Updates'),
|
||||
bundles: t('appstore', 'App bundles'),
|
||||
featured: t('appstore', 'Featured apps'),
|
||||
supported: t('appstore', 'Supported apps'), // From subscription
|
||||
})
|
||||
|
||||
/**
|
||||
* SVG paths used for appstore category icons
|
||||
*/
|
||||
export const APPSTORE_CATEGORY_ICONS = Object.freeze({
|
||||
// system special categories
|
||||
discover: mdiStarCircleOutline,
|
||||
installed: mdiAccountOutline,
|
||||
enabled: mdiCheck,
|
||||
disabled: mdiClose,
|
||||
bundles: mdiArchiveOutline,
|
||||
supported: mdiStarShootingOutline,
|
||||
featured: mdiStar,
|
||||
updates: mdiDownload,
|
||||
|
||||
// generic category
|
||||
ai: mdiCreationOutline,
|
||||
auth: mdiKeyOutline,
|
||||
customization: mdiCogOutline,
|
||||
dashboard: mdiViewColumnOutline,
|
||||
files: mdiFolder,
|
||||
games: mdiControllerClassicOutline,
|
||||
integration: mdiOpenInApp,
|
||||
monitoring: mdiMonitorEye,
|
||||
multimedia: mdiMultimedia,
|
||||
office: mdiFileDocumentEdit,
|
||||
organization: mdiOfficeBuildingOutline,
|
||||
search: mdiMagnify,
|
||||
security: mdiSecurity,
|
||||
social: mdiAccountMultipleOutline,
|
||||
tools: mdiTools,
|
||||
workflow: mdiClipboardFlowOutline,
|
||||
})
|
||||
|
||||
/**
|
||||
* Currently known types of app discover section elements
|
||||
*/
|
||||
export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
import AppstoreApp from './AppstoreApp.vue'
|
||||
import router from './router/index.ts'
|
||||
|
||||
import 'vite/modulepreload-polyfill'
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(AppstoreApp)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#content')
|
||||
+20
-20
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import rebuildNavigation from '../service/rebuild-navigation.js'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
|
||||
const productName = window.OC.theme.productName
|
||||
|
||||
@@ -47,64 +47,64 @@ export default {
|
||||
},
|
||||
updateButtonText() {
|
||||
if (this.app?.app_api && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
|
||||
return t('settings', 'Manually installed apps cannot be updated')
|
||||
return t('appstore', 'Manually installed apps cannot be updated')
|
||||
}
|
||||
return t('settings', 'Update to {version}', { version: this.app?.update })
|
||||
return t('appstore', 'Update to {version}', { version: this.app?.update })
|
||||
},
|
||||
enableButtonText() {
|
||||
if (this.app?.app_api) {
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
|
||||
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
|
||||
return t('appstore', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
|
||||
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
|
||||
return t('appstore', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
|
||||
return t('settings', 'Health checking')
|
||||
return t('appstore', 'Health checking')
|
||||
}
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Deploy and Enable')
|
||||
return t('appstore', 'Deploy and Enable')
|
||||
}
|
||||
return t('settings', 'Enable')
|
||||
return t('appstore', 'Enable')
|
||||
} else {
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Download and enable')
|
||||
return t('appstore', 'Download and enable')
|
||||
}
|
||||
return t('settings', 'Enable')
|
||||
return t('appstore', 'Enable')
|
||||
}
|
||||
},
|
||||
disableButtonText() {
|
||||
if (this.app?.app_api) {
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
|
||||
return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
|
||||
return t('appstore', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
|
||||
return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init })
|
||||
return t('appstore', '{progress}% Initializing …', { progress: this.app?.status?.init })
|
||||
}
|
||||
if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
|
||||
return t('settings', 'Health checking')
|
||||
return t('appstore', 'Health checking')
|
||||
}
|
||||
}
|
||||
return t('settings', 'Disable')
|
||||
return t('appstore', 'Disable')
|
||||
},
|
||||
forceEnableButtonText() {
|
||||
if (this.app.needsDownload) {
|
||||
return t('settings', 'Allow untested app')
|
||||
return t('appstore', 'Allow untested app')
|
||||
}
|
||||
return t('settings', 'Allow untested app')
|
||||
return t('appstore', 'Allow untested app')
|
||||
},
|
||||
enableButtonTooltip() {
|
||||
if (!this.app?.app_api && this.app.needsDownload) {
|
||||
return t('settings', 'The app will be downloaded from the App Store')
|
||||
return t('appstore', 'The app will be downloaded from the App Store')
|
||||
}
|
||||
return null
|
||||
},
|
||||
forceEnableButtonTooltip() {
|
||||
const base = t('settings', 'This app is not marked as compatible with your {productName} version.', { productName })
|
||||
const base = t('appstore', 'This app is not marked as compatible with your {productName} version.', { productName })
|
||||
+ ' '
|
||||
+ t('settings', 'If you continue you will still be able to install the app. Note that the app might not work as expected.')
|
||||
+ t('appstore', 'If you continue you will still be able to install the app. Note that the app might not work as expected.')
|
||||
if (this.app.needsDownload) {
|
||||
return base + ' ' + t('settings', 'The app will be downloaded from the App Store')
|
||||
return base + ' ' + t('appstore', 'The app will be downloaded from the App Store')
|
||||
}
|
||||
return base
|
||||
},
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import routes from './routes.ts'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(generateUrl('')),
|
||||
linkActiveClass: 'active',
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,59 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('appstore', 'appstoreEnabled', true)
|
||||
|
||||
// Dynamic loading
|
||||
const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue'))
|
||||
const AppstoreManage = defineAsyncComponent(() => import('../views/AppstoreManage.vue'))
|
||||
const AppstoreBundles = defineAsyncComponent(() => import('../views/AppstoreBundles.vue'))
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/:index(index.php/)?settings/apps',
|
||||
name: 'apps',
|
||||
redirect: appstoreEnabled
|
||||
? {
|
||||
name: 'apps-discover',
|
||||
}
|
||||
: {
|
||||
name: 'apps-manage',
|
||||
params: { category: 'installed' },
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'discover/:id?',
|
||||
name: 'apps-discover',
|
||||
component: AppstoreDiscover,
|
||||
},
|
||||
{
|
||||
path: 'bundles/:id?',
|
||||
name: 'apps-bundles',
|
||||
component: AppstoreBundles,
|
||||
},
|
||||
{
|
||||
path: ':category(installed|enabled|disabled|updates)/:id?',
|
||||
name: 'apps-manage',
|
||||
component: AppstoreManage,
|
||||
},
|
||||
{
|
||||
path: 'apps/:category',
|
||||
name: 'apps-category',
|
||||
children: [{
|
||||
path: ':id',
|
||||
name: 'apps-details',
|
||||
component: {},
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
+11
-9
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const OFFICE_SUITES = [
|
||||
import { t } from "@nextcloud/l10n"
|
||||
|
||||
export const OFFICE_SUITES = Object.freeze([
|
||||
{
|
||||
id: 'nextcloud-office',
|
||||
appId: 'richdocuments',
|
||||
@@ -33,24 +35,24 @@ export const OFFICE_SUITES = [
|
||||
learnMoreUrl: 'https://nextcloud.com/onlyoffice/',
|
||||
isPrimary: false,
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
/**
|
||||
* Get office suite configuration by ID
|
||||
*
|
||||
* @param {string} id - The suite ID
|
||||
* @return {object|undefined} The suite configuration or undefined if not found
|
||||
* @param id - The suite ID
|
||||
* @return The suite configuration or undefined if not found
|
||||
*/
|
||||
export function getOfficeSuiteById(id) {
|
||||
export function getOfficeSuiteById(id: string) {
|
||||
return OFFICE_SUITES.find((suite) => suite.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get office suite configuration by app ID
|
||||
*
|
||||
* @param {string} appId - The app ID (richdocuments, onlyoffice, etc.)
|
||||
* @return {object|undefined} The suite configuration or undefined if not found
|
||||
* @param appId - The app ID (richdocuments, onlyoffice, etc.)
|
||||
* @return The suite configuration or undefined if not found
|
||||
*/
|
||||
export function getOfficeSuiteByAppId(appId) {
|
||||
export function getOfficeSuiteByAppId(appId: string) {
|
||||
return OFFICE_SUITES.find((suite) => suite.appId === appId)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { IAppstoreApp, IAppstoreCategory } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import PQueue from 'p-queue'
|
||||
import { APPSTORE_CATEGORY_ICONS } from '../constants.ts'
|
||||
|
||||
const BASE_URL = generateOcsUrl('apps/appstore/api/v1')
|
||||
const Url = Object.freeze({
|
||||
apps: `${BASE_URL}/apps`,
|
||||
categories: `${BASE_URL}/apps/categories`,
|
||||
enable: `${BASE_URL}/apps/enable`,
|
||||
forceEnable: `${BASE_URL}/apps/force`,
|
||||
disable: `${BASE_URL}/apps/disable`,
|
||||
uninstall: `${BASE_URL}/apps/uninstall`,
|
||||
update: `${BASE_URL}/apps/update`,
|
||||
})
|
||||
|
||||
const queue = new PQueue({ concurrency: 1 })
|
||||
|
||||
/**
|
||||
* Force enable app by ignoring its dependencies
|
||||
*
|
||||
* @param appId - The app to force enable
|
||||
*/
|
||||
export async function forceEnableApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await confirmPassword()
|
||||
await axios.post(Url.forceEnable, { appId })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an app by its app id
|
||||
*
|
||||
* @param appId - The app to enable
|
||||
*/
|
||||
export async function enableApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await confirmPassword()
|
||||
await axios.post(Url.enable, { appId })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable app by its app id
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
export async function disableApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await confirmPassword()
|
||||
await axios.post(Url.disable, { appId })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an app by its app id
|
||||
*
|
||||
* @param appId - The app id to update
|
||||
*/
|
||||
export async function updateApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await confirmPassword()
|
||||
await axios.post(Url.update, { appId })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its app id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
export async function uninstallApp(appId: string) {
|
||||
return queue.add(async () => {
|
||||
await confirmPassword()
|
||||
await axios.post(Url.uninstall, { appId })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps from the appstore
|
||||
*/
|
||||
export async function getApps() {
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreApp[]>>(Url.apps)
|
||||
return data.ocs.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app categories
|
||||
*/
|
||||
export async function getCategories() {
|
||||
const { data } = await axios.get<OCSResponse<IAppstoreCategory[]>>(Url.categories)
|
||||
for (const category of data.ocs.data) {
|
||||
category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? ''
|
||||
}
|
||||
return data.ocs.data
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { filterElements, parseApiResponse } from '../utils/appDiscoverParser.ts'
|
||||
|
||||
/**
|
||||
* Get app discover elements
|
||||
*/
|
||||
export async function getDiscoverElements() {
|
||||
const data = await loadDiscoverElements()
|
||||
if (data.length === 0) {
|
||||
throw new Error('No app discover elements available (empty response)')
|
||||
}
|
||||
|
||||
// Parse data to ensure dates are useable and then filter out expired or future elements
|
||||
const parsedElements = data.map(parseApiResponse)
|
||||
.filter(filterElements)
|
||||
|
||||
// Shuffle elements to make it looks more interesting
|
||||
const shuffledElements = shuffleArray(parsedElements)
|
||||
// Sort pinned elements first
|
||||
shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
|
||||
return shuffledElements
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle using the Fisher-Yates algorithm
|
||||
*
|
||||
* @param array The array to shuffle (in place)
|
||||
*/
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/**
|
||||
* Load discover elements from the API
|
||||
*/
|
||||
async function loadDiscoverElements() {
|
||||
const url = generateUrl('/apps/appstore/api/v1/discover')
|
||||
const { data } = await axios.get<Record<string, unknown>[]>(url)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*!
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IDeployOptions } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
/**
|
||||
* Fetch all external (app_api) apps from the server.
|
||||
*/
|
||||
export async function fetchApps() {
|
||||
const { data } = await axios.get(generateUrl('/apps/app_api/apps/list'))
|
||||
return data.apps as IAppstoreExApp[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an external app.
|
||||
*
|
||||
* @param app - The app to enable
|
||||
* @param daemon - The daemon to use for deployment
|
||||
* @param deployOptions - Additional options for deployment
|
||||
*/
|
||||
export async function enableExApp(app: IAppstoreExApp, daemon: IDeployDaemon, deployOptions?: IDeployOptions) {
|
||||
await confirmPassword()
|
||||
await axios.post(generateUrl(`/apps/app_api/apps/enable/${app.id}/${daemon.name}`), { deployOptions })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force enable an external app
|
||||
*
|
||||
* @param appId - The app to force-enable
|
||||
*/
|
||||
export async function forceEnableExApp(appId: string) {
|
||||
await confirmPassword()
|
||||
await axios.post(generateUrl('/apps/app_api/apps/force'), { appId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an external app.
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
export async function disableExApp(appId: string) {
|
||||
await confirmPassword()
|
||||
await axios.get(generateUrl(`apps/app_api/apps/disable/${appId}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an external app.
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
* @param removeData - If all data should be removed
|
||||
*/
|
||||
export async function uninstallExApp(appId: string, removeData = false) {
|
||||
await confirmPassword()
|
||||
await axios.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
|
||||
/**
|
||||
* Rebuilds the app navigation menu
|
||||
*/
|
||||
export async function rebuildNavigation() {
|
||||
const { data } = await axios.get<OCSResponse>(generateOcsUrl('core/navigation/apps?format=json'))
|
||||
if (data.ocs.meta.statuscode !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('nextcloud:app-menu.refresh', { apps: data.ocs.data })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreCategory, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import * as api from '../service/api.ts'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
import { canDisable, canForceEnable, canInstall, canUninstall } from '../utils/appStatus.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import { useExAppsStore } from './exApps.ts'
|
||||
|
||||
export const useAppsStore = defineStore('apps', () => {
|
||||
const exApps = useExAppsStore()
|
||||
|
||||
/**
|
||||
* All apps available in the appstore
|
||||
*/
|
||||
const appstoreApps = ref<IAppstoreApp[]>([])
|
||||
/**
|
||||
* All app categories available in the appstore
|
||||
*/
|
||||
const categories = ref<IAppstoreCategory[]>([])
|
||||
/**
|
||||
* Loading state of the store
|
||||
*/
|
||||
const isLoadingApps = ref(false)
|
||||
const isLoadingCategories = ref(false)
|
||||
|
||||
/**
|
||||
* All apps available
|
||||
*/
|
||||
const apps = computed(() => [...appstoreApps.value, ...(exApps.isEnabled ? exApps.apps : [])])
|
||||
|
||||
/**
|
||||
* Get a category by its id
|
||||
*
|
||||
* @param categoryId - The id of the category
|
||||
*/
|
||||
function getCategoryById(categoryId: string) {
|
||||
return categories.value.find(({ id }) => id === categoryId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an app by its id
|
||||
*
|
||||
* @param appId - The id of the app
|
||||
*/
|
||||
function getAppById(appId: string): IAppstoreApp | IAppstoreExApp | null {
|
||||
return apps.value.find(({ id }) => id === appId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps of a category
|
||||
*
|
||||
* @param categoryId - The id of the category
|
||||
*/
|
||||
function getAppsByCategory(categoryId: string): (IAppstoreApp | IAppstoreExApp)[] {
|
||||
return apps.value.filter((app) => [app.category].flat().includes(categoryId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an app by its id
|
||||
*
|
||||
* @param appId - The app to enable
|
||||
*/
|
||||
async function enableApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (app.active || (!app.installed && !canInstall(app))) {
|
||||
throw new Error(`App with id ${appId} cannot be enabled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.enableApp(appId)
|
||||
} else {
|
||||
await api.enableApp(appId)
|
||||
}
|
||||
app.active = true
|
||||
app.installed = true
|
||||
app.removable = true
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force enable an app
|
||||
* @param appId - The app to force-enable
|
||||
*/
|
||||
async function forceEnableApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canForceEnable(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be force-enabled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.enableApp(appId)
|
||||
} else {
|
||||
await api.forceEnableApp(appId)
|
||||
}
|
||||
app.active = true
|
||||
app.installed = true
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an app by its id
|
||||
*
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
async function disableApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canDisable(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be disabled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.disableApp(appId)
|
||||
} else {
|
||||
await api.disableApp(appId)
|
||||
}
|
||||
app.active = false
|
||||
await rebuildNavigation()
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
async function uninstallApp(appId: string) {
|
||||
const app = getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
if (!canUninstall(app)) {
|
||||
throw new Error(`App with id ${appId} cannot be uninstalled`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (app.app_api) {
|
||||
await exApps.uninstallApp(appId)
|
||||
} else {
|
||||
await api.uninstallApp(appId)
|
||||
}
|
||||
app.installed = false
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the groups of an app
|
||||
*
|
||||
* @param appId - The app to update
|
||||
* @param groups - The new groups
|
||||
*/
|
||||
function updateAppGroups(appId: string, groups: string[]) {
|
||||
const app = apps.value.find(({ id }) => id === appId)
|
||||
if (app) {
|
||||
app.groups = [...groups]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the app categories from the backend
|
||||
*/
|
||||
async function loadCategories() {
|
||||
try {
|
||||
isLoadingCategories.value = true
|
||||
categories.value = await api.getCategories()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load app categories', { error })
|
||||
showError(t('appstore', 'Could not load app categories. Please try again later.'))
|
||||
} finally {
|
||||
isLoadingCategories.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the apps from the backend
|
||||
*/
|
||||
async function loadApps() {
|
||||
try {
|
||||
isLoadingApps.value = true
|
||||
appstoreApps.value = await api.getApps()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load apps list', { error })
|
||||
showError(t('appstore', 'Could not load apps list. Please try again later.'))
|
||||
} finally {
|
||||
isLoadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// initialize store
|
||||
loadApps()
|
||||
loadCategories()
|
||||
|
||||
return {
|
||||
apps,
|
||||
categories,
|
||||
isLoadingApps,
|
||||
isLoadingCategories,
|
||||
|
||||
disableApp,
|
||||
enableApp,
|
||||
forceEnableApp,
|
||||
uninstallApp,
|
||||
|
||||
getAppById,
|
||||
getAppsByCategory,
|
||||
getCategoryById,
|
||||
updateAppGroups,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreExApp, IDeployDaemon, IExAppStatus } from '../apps.d.ts'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { spawnDialog } from '@nextcloud/vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import DaemonSelectionDialog from '../components/DaemonSelectionDialog/DaemonSelectionDialog.vue'
|
||||
import * as exAppApi from '../service/exAppApi.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
export const useExAppsStore = defineStore('external-apps', () => {
|
||||
/**
|
||||
* Is the App API enabled
|
||||
*/
|
||||
const isEnabled = loadState('appstore', 'appApiEnabled', false)
|
||||
|
||||
/**
|
||||
* All external apps available
|
||||
*/
|
||||
const apps = ref<IAppstoreExApp[]>([])
|
||||
|
||||
/**
|
||||
* Number of external apps with available updates, used to show the update badge in the UI
|
||||
*/
|
||||
const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0))
|
||||
|
||||
const statusUpdater = ref<number | null | undefined>(null)
|
||||
const daemonAccessible = ref(loadState('appstore', 'defaultDaemonConfigAccessible', false))
|
||||
const defaultDaemon = ref(loadState<IDeployDaemon | null>('appstore', 'defaultDaemonConfig', null))
|
||||
const dockerDaemons = ref<IDeployDaemon[]>([])
|
||||
|
||||
const initializingOrDeployingApps = computed(() => apps.value.filter((app) => app?.status?.action
|
||||
&& (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')
|
||||
&& app.status.type !== ''))
|
||||
|
||||
/**
|
||||
* Get an external app by its ID
|
||||
*
|
||||
* @param appId - The app ID
|
||||
*/
|
||||
function getById(appId: string): IAppstoreExApp | null {
|
||||
return apps.value.find(({ id }) => id === appId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable an exApp.
|
||||
*
|
||||
* @param appId - The app ID
|
||||
* @param daemon - The daemon to use for deployment
|
||||
* @param deployOptions - Additional options for deployment
|
||||
*/
|
||||
async function enableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
if (dockerDaemons.value.length === 1 && app.needsDownload) {
|
||||
exAppApi.enableExApp(app, dockerDaemons[0])
|
||||
app.daemon = dockerDaemons[0]
|
||||
} else if (app.needsDownload) {
|
||||
const daemon = await spawnDialog(DaemonSelectionDialog, { app })
|
||||
if (!daemon) {
|
||||
throw new Error('No daemon selected')
|
||||
}
|
||||
await exAppApi.enableExApp(app, daemon)
|
||||
app.daemon = daemon
|
||||
} else {
|
||||
await exAppApi.enableExApp(app, app.daemon!)
|
||||
}
|
||||
|
||||
if (!app.installed) {
|
||||
app.needsDownload = false
|
||||
app.status = {
|
||||
type: 'install',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} as IExAppStatus
|
||||
}
|
||||
app.removable = true
|
||||
delete app.error
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force enable an exApp by ignoring its dependencies.
|
||||
*
|
||||
* @param appId - The app to force-enable
|
||||
*/
|
||||
async function forceEnableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.forceEnableExApp(appId)
|
||||
await initialize(true)
|
||||
app.active = false
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param appId - The app to disable
|
||||
*/
|
||||
async function disableApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.disableExApp(appId)
|
||||
app.active = false
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app by its id
|
||||
*
|
||||
* @param appId - The app to uninstall
|
||||
*/
|
||||
async function uninstallApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await exAppApi.disableExApp(appId)
|
||||
app.active = false
|
||||
app.needsDownload = true
|
||||
app.installed = false
|
||||
app.daemon = null
|
||||
app.status = {}
|
||||
if (app.update !== null) {
|
||||
updateCount.value--
|
||||
}
|
||||
delete app.update
|
||||
delete app.error
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an external app
|
||||
*
|
||||
* @param appId - The app ID
|
||||
*/
|
||||
async function updateApp(appId: string) {
|
||||
const app = getById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
app.loading = true
|
||||
try {
|
||||
await axios.get(generateUrl(`/apps/app_api/apps/update/${appId}`))
|
||||
app.version = app.update || app.version
|
||||
app.status = {
|
||||
type: 'update',
|
||||
action: 'deploy',
|
||||
init: 0,
|
||||
deploy: 0,
|
||||
} satisfies IExAppStatus
|
||||
delete app.update
|
||||
delete app.error
|
||||
updateCount.value--
|
||||
// Trigger status updates
|
||||
// updateAppsStatus()
|
||||
} catch (error) {
|
||||
logger.error('Failed to update ex app', { appId, error })
|
||||
showError(t('appstore', 'Could not update the app. Please try again later.'))
|
||||
} finally {
|
||||
app.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store.
|
||||
* This only needs to be called when an app management operation is performed.
|
||||
*
|
||||
* @param force - If the initialization should be forced (to run again)
|
||||
*/
|
||||
async function initialize(force = false) {
|
||||
if (force || (!defaultDaemon.value || !dockerDaemons.value.length)) {
|
||||
await fetchDockerDaemons()
|
||||
}
|
||||
if (force || apps.value.length === 0) {
|
||||
await fetchAllApps()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
|
||||
apps,
|
||||
updateCount,
|
||||
defaultDaemon,
|
||||
dockerDaemons,
|
||||
|
||||
getById,
|
||||
disableApp,
|
||||
enableApp,
|
||||
forceEnableApp,
|
||||
updateApp,
|
||||
uninstallApp,
|
||||
initialize,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the configured docker daemons from the backend.
|
||||
*/
|
||||
async function fetchDockerDaemons() {
|
||||
try {
|
||||
const { data } = await axios.get(generateUrl('/apps/app_api/daemons'))
|
||||
defaultDaemon.value = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config)
|
||||
dockerDaemons.value = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install')
|
||||
} catch (error) {
|
||||
logger.error('[app-api-store] Failed to fetch Docker daemons', { error })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function fetchAllApps() {
|
||||
try {
|
||||
apps.value = await exAppApi.fetchApps()
|
||||
} catch (error) {
|
||||
logger.error('An error occurred while fetching apps', { error })
|
||||
showError(t('appstore', 'An error occurred during the request. Unable to proceed.'))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
async fetchAppStatus(appId: string) {
|
||||
return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`))
|
||||
.then((response) => {
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
if (app) {
|
||||
app.status = response.data
|
||||
}
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
logger.debug('initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps })
|
||||
if (initializingOrDeployingApps.length === 0) {
|
||||
logger.debug('clearing interval')
|
||||
clearInterval(this.statusUpdater as number)
|
||||
this.statusUpdater = null
|
||||
}
|
||||
if (Object.hasOwn(response.data, 'error')
|
||||
&& response.data.error !== ''
|
||||
&& initializingOrDeployingApps.length === 1) {
|
||||
clearInterval(this.statusUpdater as number)
|
||||
this.statusUpdater = null
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.appsApiFailure({ appId, error })
|
||||
this.apps = this.apps.filter((app) => app.id !== appId)
|
||||
this.updateAppsStatus()
|
||||
})
|
||||
},
|
||||
|
||||
updateAppsStatus() {
|
||||
clearInterval(this.statusUpdater as number)
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
if (initializingOrDeployingApps.length === 0) {
|
||||
return
|
||||
}
|
||||
this.statusUpdater = setInterval(() => {
|
||||
const initializingOrDeployingApps = this.getInitializingOrDeployingApps
|
||||
logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps })
|
||||
initializingOrDeployingApps.forEach((app) => {
|
||||
this.fetchAppStatus(app.id)
|
||||
})
|
||||
}, 2000) as unknown as number
|
||||
},
|
||||
}, */
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import * as api from '../service/api.ts'
|
||||
import { rebuildNavigation } from '../service/rebuild-navigation.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
import { useAppsStore } from './apps.ts'
|
||||
import { useExAppsStore } from './exApps.ts'
|
||||
|
||||
export const useUpdatesStore = defineStore('updates', () => {
|
||||
const exApps = useExAppsStore()
|
||||
|
||||
/**
|
||||
* Number of apps with available updates
|
||||
*/
|
||||
const internalUpdateCount = ref(loadState<number>('appstore', 'appstoreUpdateCount', 0))
|
||||
|
||||
/**
|
||||
* Total number of apps with available updates
|
||||
*/
|
||||
const updateCount = computed(() => internalUpdateCount.value + exApps.updateCount)
|
||||
|
||||
/**
|
||||
* Update the given app
|
||||
*
|
||||
* @param appId - The app id to update
|
||||
* @throws {Error} if the app is not found
|
||||
*/
|
||||
async function updateApp(appId: string) {
|
||||
const store = useAppsStore()
|
||||
|
||||
const app = store.getAppById(appId)
|
||||
if (!app) {
|
||||
throw new Error(`App with id ${appId} not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
if ('app_api' in app && app.app_api) {
|
||||
await exApps.updateApp(appId)
|
||||
} else {
|
||||
await api.updateApp(appId)
|
||||
internalUpdateCount.value = Math.max(internalUpdateCount.value - 1, 0)
|
||||
}
|
||||
|
||||
rebuildNavigation()
|
||||
} catch (error) {
|
||||
logger.error('Failed to update app', { appId, error })
|
||||
showError(t('appstore', 'Could not update the app. Please try again later.'))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateCount,
|
||||
updateApp,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/*!
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
/**
|
||||
* Check if an app can be installed.
|
||||
*
|
||||
* @param app - The app to check if installable
|
||||
*/
|
||||
export function canInstall(app: IAppstoreApp | IAppstoreExApp) {
|
||||
if (app.installed || app.internal) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (app.missingDependencies === undefined || app.missingDependencies.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!app.isCompatible && app.missingDependencies.length === 1) {
|
||||
// incompatible so can be installed but has to be force-enabled
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be uninstalled.
|
||||
*
|
||||
* @param app - The app to check if uninstallable
|
||||
*/
|
||||
export function canUninstall(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.installed && app.removable
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app can be enabled.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return canForceEnable(app) && app.isCompatible
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be force-enabled
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canForceEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !app.active && (app.installed || canInstall(app))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app needs to be force-enabled
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !app.active && !app.isCompatible
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be disabled.
|
||||
*
|
||||
* @param app - The app to check
|
||||
*/
|
||||
export function canDisable(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.active && !app.internal
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app can be updated.
|
||||
*
|
||||
* @param app - The app to check if update-able
|
||||
*/
|
||||
export function canUpdate(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.update !== undefined
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { AxiosError } from '@nextcloud/axios'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
/**
|
||||
* @param error the error
|
||||
* @param message the message to display
|
||||
*/
|
||||
export function handleError(error: AxiosError, message: string) {
|
||||
let fullMessage = ''
|
||||
|
||||
if (message) {
|
||||
fullMessage += message
|
||||
}
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
if (fullMessage) {
|
||||
fullMessage += '\n'
|
||||
}
|
||||
fullMessage += t('appstore', 'There were too many requests from your network. Retry later or contact your administrator if this is an error.')
|
||||
}
|
||||
|
||||
fullMessage = fullMessage || t('appstore', 'Error')
|
||||
showError(fullMessage)
|
||||
logger.error(fullMessage, { error })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getLoggerBuilder } from '@nextcloud/logger'
|
||||
|
||||
export default getLoggerBuilder()
|
||||
.setApp('appstore')
|
||||
.detectUser()
|
||||
.build()
|
||||
@@ -15,7 +15,7 @@
|
||||
<NcEmptyContent
|
||||
v-else-if="isLoading"
|
||||
class="empty-content__loading"
|
||||
:name="t('settings', 'Loading app list')">
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
@@ -33,7 +33,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppList from '../components/AppList.vue'
|
||||
import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
|
||||
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
|
||||
import { APPSTORE_CATEGORY_NAMES } from '../constants.ts'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
|
||||
@@ -46,9 +46,9 @@ const appApiStore = useAppApiStore()
|
||||
*/
|
||||
const currentCategory = computed(() => route.params?.category ?? 'discover')
|
||||
|
||||
const viewLabel = computed<string>(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName)
|
||||
const viewLabel = computed<string>(() => APPSTORE_CATEGORY_NAMES[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName)
|
||||
|
||||
const pageHeading = t('settings', 'App Store')
|
||||
const pageHeading = t('appstore', 'App Store')
|
||||
const pageTitle = computed(() => `${viewLabel.value} - ${pageHeading}`) // NcAppContent automatically appends the instance name
|
||||
|
||||
// TODO this part should be migrated to pinia
|
||||
@@ -78,10 +78,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.app-settings-content__label {
|
||||
margin-block-start: var(--app-navigation-padding);
|
||||
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
|
||||
min-height: var(--default-clickable-area);
|
||||
line-height: var(--default-clickable-area);
|
||||
vertical-align: center;
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiDownloadMultiple } from '@mdi/js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canEnable } from '../utils/appStatus.ts'
|
||||
|
||||
type AppBundle = {
|
||||
id: string
|
||||
name: string
|
||||
appIdentifiers: string[]
|
||||
}
|
||||
|
||||
const store = useAppsStore()
|
||||
const bundles = loadState<AppBundle[]>('appstore', 'appstoreBundles')
|
||||
|
||||
const appBundles = computed(() => bundles.map((bundle) => ({
|
||||
id: bundle.id,
|
||||
name: bundle.name,
|
||||
apps: bundle.appIdentifiers
|
||||
.map((id) => store.apps.find((app) => app.id === id))
|
||||
.filter(Boolean) as IAppstoreApp[],
|
||||
isEnabling: false,
|
||||
})))
|
||||
|
||||
/**
|
||||
* Enable all apps in a bundle
|
||||
*
|
||||
* @param bundle - The bundle to enable all apps
|
||||
*/
|
||||
async function enableAll(bundle: typeof appBundles.value[number]) {
|
||||
bundle.isEnabling = true
|
||||
for (const app of bundle.apps) {
|
||||
if (!canEnable(app)) {
|
||||
// already active or needs force-enabling
|
||||
continue
|
||||
}
|
||||
await store.enableApp(app.id)
|
||||
}
|
||||
bundle.isEnabling = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="bundle of appBundles" :key="bundle.id">
|
||||
<div :class="$style.appstoreBundles__header">
|
||||
<h3>{{ bundle.name }}</h3>
|
||||
<NcButton variant="primary" @click="enableAll(bundle)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiDownloadMultiple" />
|
||||
</template>
|
||||
{{ t('appstore', 'Download and enable all') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<AppTable
|
||||
:class="$style.appstoreBundles__appTable"
|
||||
:apps="bundle.apps" />
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreBundles__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--default-clickable-area);
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.appstoreBundles__appTable:last-of-type {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppDiscoverElements } from '../apps-discover.d.ts'
|
||||
|
||||
import { mdiEyeOffOutline } from '@mdi/js'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { getDiscoverElements } from '../service/app-discover.ts'
|
||||
import logger from '../utils/logger.ts'
|
||||
|
||||
const PostType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypePost.vue'))
|
||||
const CarouselType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeCarousel.vue'))
|
||||
const ShowcaseType = defineAsyncComponent(() => import('../components/DiscoverType/DiscoverTypeShowcase.vue'))
|
||||
|
||||
const hasError = ref(false)
|
||||
const elements = ref<IAppDiscoverElements[]>([])
|
||||
|
||||
/**
|
||||
* Load the app discover section information
|
||||
*/
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
// Set the elements to the UI
|
||||
elements.value = await getDiscoverElements()
|
||||
} catch (error) {
|
||||
hasError.value = true
|
||||
logger.error(error as Error)
|
||||
showError(t('appstore', 'Could not load app discover section'))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the component for the given type
|
||||
*
|
||||
* @param type - The type of the component
|
||||
*/
|
||||
function getComponent(type: IAppDiscoverElements['type']) {
|
||||
if (type === 'post') {
|
||||
return PostType
|
||||
} else if (type === 'carousel') {
|
||||
return CarouselType
|
||||
} else if (type === 'showcase') {
|
||||
return ShowcaseType
|
||||
}
|
||||
return defineComponent({
|
||||
mounted: () => logger.error('Unknown component requested ', type),
|
||||
render: (h) => h('div', t('appstore', 'Could not render element')),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcEmptyContent
|
||||
v-if="hasError"
|
||||
:name="t('appstore', 'Nothing to show')"
|
||||
:description="t('appstore', 'Could not load section content from app store.')">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent
|
||||
v-else-if="elements.length === 0"
|
||||
:name="t('appstore', 'Loading')"
|
||||
:description="t('appstore', 'Fetching the latest news…')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<div v-else class="app-discover">
|
||||
<component
|
||||
:is="getComponent(entry.type)"
|
||||
v-for="entry, index in elements"
|
||||
:key="entry.id ?? index"
|
||||
v-bind="entry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-discover {
|
||||
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
|
||||
margin-inline: auto;
|
||||
padding-inline: 54px;
|
||||
/* Padding required to make last element not bound to the bottom */
|
||||
padding-block-end: var(--default-clickable-area, 44px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-clickable-area, 44px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppTable from '../components/AppTable/AppTable.vue'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
|
||||
const currentCategory = computed(() => route.params!.category as 'enabled' | 'installed' | 'disabled' | 'updates')
|
||||
const apps = computed(() => {
|
||||
if (currentCategory.value === 'installed') {
|
||||
return store.apps.filter((app) => app.installed)
|
||||
} else if (currentCategory.value === 'enabled') {
|
||||
return store.apps.filter((app) => app.active)
|
||||
} else if (currentCategory.value === 'disabled') {
|
||||
return store.apps.filter((app) => app.installed && !app.active)
|
||||
} else if (currentCategory.value === 'updates') {
|
||||
return store.apps.filter((app) => app.update)
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Apps list -->
|
||||
<NcEmptyContent
|
||||
v-if="store.isLoadingApps"
|
||||
:name="t('appstore', 'Loading app list')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<AppTable
|
||||
v-else
|
||||
:class="$style.appstoreManage__appTable"
|
||||
:apps />
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreManage__appTable {
|
||||
margin-bottom: var(--body-container-margin);
|
||||
}
|
||||
</style>
|
||||
+80
-62
@@ -2,59 +2,111 @@
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
|
||||
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { APPSTORE_CATEGORY_ICONS, APPSTORE_CATEGORY_NAMES } from '../constants.ts'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
|
||||
|
||||
const store = useAppsStore()
|
||||
const updateStore = useUpdatesStore()
|
||||
const categories = computed(() => store.categories)
|
||||
const categoriesLoading = computed(() => store.isLoadingCategories)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
watch(search, (newValue, oldValue) => {
|
||||
if (newValue.trim() === oldValue.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.name === 'apps-search') {
|
||||
router.replace({
|
||||
name: 'apps-search',
|
||||
query: { q: newValue },
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'apps-search',
|
||||
query: { q: newValue },
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the current instance has a support subscription from the Nextcloud GmbH
|
||||
*
|
||||
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
|
||||
*/
|
||||
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Categories & filters -->
|
||||
<NcAppNavigation :aria-label="t('settings', 'Apps')">
|
||||
<NcAppNavigation :aria-label="t('appstore', 'Apps')">
|
||||
<template #search>
|
||||
<NcAppNavigationSearch
|
||||
v-model="search"
|
||||
:label="t('appstore', 'Search apps…')" />
|
||||
</template>
|
||||
<template #list>
|
||||
<NcAppNavigationItem
|
||||
v-if="appstoreEnabled"
|
||||
id="app-category-discover"
|
||||
:to="{ name: 'apps-category', params: { category: 'discover' } }"
|
||||
:name="APPS_SECTION_ENUM.discover">
|
||||
:to="{ name: 'apps-discover' }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.discover">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-installed"
|
||||
:to="{ name: 'apps-category', params: { category: 'installed' } }"
|
||||
:name="APPS_SECTION_ENUM.installed">
|
||||
:to="{ name: 'apps-manage', params: { category: 'installed' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.installed">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-enabled"
|
||||
:to="{ name: 'apps-category', params: { category: 'enabled' } }"
|
||||
:name="APPS_SECTION_ENUM.enabled">
|
||||
:to="{ name: 'apps-manage', params: { category: 'enabled' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.enabled">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.enabled" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-disabled"
|
||||
:to="{ name: 'apps-category', params: { category: 'disabled' } }"
|
||||
:name="APPS_SECTION_ENUM.disabled">
|
||||
:to="{ name: 'apps-manage', params: { category: 'disabled' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.disabled">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.disabled" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
v-if="store.updateCount > 0"
|
||||
id="app-category-updates"
|
||||
:to="{ name: 'apps-category', params: { category: 'updates' } }"
|
||||
:name="APPS_SECTION_ENUM.updates">
|
||||
v-if="updateStore.updateCount > 0"
|
||||
:to="{ name: 'apps-manage', params: { category: 'updates' } }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.updates">
|
||||
<template #counter>
|
||||
<NcCounterBubble>{{ store.updateCount }}</NcCounterBubble>
|
||||
<NcCounterBubble :count="updateStore.updateCount" />
|
||||
</template>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.updates" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem
|
||||
id="app-category-your-bundles"
|
||||
:to="{ name: 'apps-category', params: { category: 'app-bundles' } }"
|
||||
:name="APPS_SECTION_ENUM['app-bundles']">
|
||||
:to="{ name: 'apps-bundles' }"
|
||||
:name="APPSTORE_CATEGORY_NAMES.bundles">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.bundles" />
|
||||
</template>
|
||||
@@ -63,15 +115,16 @@
|
||||
<NcAppNavigationSpacer />
|
||||
|
||||
<!-- App store categories -->
|
||||
<li v-if="appstoreEnabled && categoriesLoading" class="categories--loading">
|
||||
<NcLoadingIcon :size="20" :aria-label="t('settings', 'Loading categories')" />
|
||||
<li v-if="appstoreEnabled && categoriesLoading" :class="$style.appstoreNavigation__categories_loading">
|
||||
<NcLoadingIcon :size="20" :name="t('appstore', 'Loading categories')" />
|
||||
</li>
|
||||
|
||||
<template v-else-if="appstoreEnabled && !categoriesLoading">
|
||||
<NcAppNavigationItem
|
||||
v-if="isSubscribed"
|
||||
id="app-category-supported"
|
||||
:to="{ name: 'apps-category', params: { category: 'supported' } }"
|
||||
:name="APPS_SECTION_ENUM.supported">
|
||||
:name="APPSTORE_CATEGORY_NAMES.supported">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.supported" />
|
||||
</template>
|
||||
@@ -79,7 +132,7 @@
|
||||
<NcAppNavigationItem
|
||||
id="app-category-featured"
|
||||
:to="{ name: 'apps-category', params: { category: 'featured' } }"
|
||||
:name="APPS_SECTION_ENUM.featured">
|
||||
:name="APPSTORE_CATEGORY_NAMES.featured">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.featured" />
|
||||
</template>
|
||||
@@ -103,43 +156,8 @@
|
||||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed, onBeforeMount } from 'vue'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
|
||||
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
|
||||
import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
|
||||
const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
|
||||
|
||||
const store = useAppsStore()
|
||||
const categories = computed(() => store.categories)
|
||||
const categoriesLoading = computed(() => store.loading.categories)
|
||||
|
||||
/**
|
||||
* Check if the current instance has a support subscription from the Nextcloud GmbH
|
||||
*
|
||||
* For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
|
||||
*/
|
||||
const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
|
||||
|
||||
// load categories when component is mounted
|
||||
onBeforeMount(() => {
|
||||
store.loadCategories()
|
||||
store.loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* The categories-loading indicator */
|
||||
.categories--loading {
|
||||
<style module>
|
||||
.appstoreNavigation__categories_loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
+67
-73
@@ -2,85 +2,35 @@
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<!-- Selected app details -->
|
||||
<NcAppSidebar
|
||||
v-if="showSidebar"
|
||||
class="app-sidebar"
|
||||
:class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
|
||||
:active.sync="activeTab"
|
||||
:background="hasScreenshot ? app.screenshot : undefined"
|
||||
:compact="!hasScreenshot"
|
||||
:name="app.name"
|
||||
:title="app.name"
|
||||
:subname="licenseText"
|
||||
:subtitle="licenseText"
|
||||
@close="hideAppDetails">
|
||||
<!-- Fallback icon incase no app icon is available -->
|
||||
<template v-if="!hasScreenshot" #header>
|
||||
<NcIconSvgWrapper
|
||||
class="app-sidebar__fallback-icon"
|
||||
:svg="appIcon ?? ''"
|
||||
:size="64" />
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<!-- Featured/Supported badges -->
|
||||
<div class="app-sidebar__badges">
|
||||
<AppLevelBadge :level="app.level" />
|
||||
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<AppScore v-if="hasRating" :score="rating" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab content -->
|
||||
<AppDescriptionTab :app="app" />
|
||||
<AppDetailsTab :key="app.id" :app="app" />
|
||||
<AppReleasesTab :app="app" />
|
||||
<AppDeployDaemonTab :app="app" />
|
||||
</NcAppSidebar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router/composables'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import AppDaemonBadge from '../components/AppList/AppDaemonBadge.vue'
|
||||
import AppLevelBadge from '../components/AppList/AppLevelBadge.vue'
|
||||
import AppScore from '../components/AppList/AppScore.vue'
|
||||
import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
|
||||
import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
|
||||
import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
|
||||
import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
|
||||
import AppDaemonBadge from '../components/AppDaemonBadge.vue'
|
||||
import AppLevelBadge from '../components/AppLevelBadge.vue'
|
||||
import AppScore from '../components/AppScore.vue'
|
||||
// import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
|
||||
// import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
|
||||
// import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
|
||||
// import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
|
||||
import { useAppIcon } from '../composables/useAppIcon.ts'
|
||||
import { useAppApiStore } from '../store/app-api-store.ts'
|
||||
import { useAppsStore } from '../store/apps-store.ts'
|
||||
import { useStore } from '../store/index.js'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppsStore()
|
||||
const appApiStore = useAppApiStore()
|
||||
const legacyStore = useStore()
|
||||
|
||||
const appId = computed(() => route.params.id ?? '')
|
||||
const app = computed(() => {
|
||||
if (legacyStore.getters.isAppApiEnabled) {
|
||||
const exApp = appApiStore.getAllApps
|
||||
.find((app) => app.id === appId.value) ?? null
|
||||
if (exApp) {
|
||||
return exApp
|
||||
}
|
||||
}
|
||||
return store.getAppById(appId.value)!
|
||||
})
|
||||
const hasRating = computed(() => app.value.appstoreData?.ratingNumOverall > 5)
|
||||
const rating = computed(() => app.value.appstoreData?.ratingNumRecent > 5
|
||||
? app.value.appstoreData.ratingRecent
|
||||
: (app.value.appstoreData?.ratingOverall ?? 0.5))
|
||||
const showSidebar = computed(() => app.value !== null)
|
||||
const appId = computed<string>(() => [route.params.id].flat()[0]!)
|
||||
const app = computed(() => store.getAppById(appId.value) ?? null)
|
||||
|
||||
const hasRating = computed(() => app.value && app.value.appstoreData?.ratingNumOverall && app.value.appstoreData?.ratingNumOverall > 5)
|
||||
const rating = computed(() => hasRating.value
|
||||
? app.value!.appstoreData!.ratingRecent
|
||||
: (app.value!.appstoreData?.ratingOverall ?? 0.5))
|
||||
|
||||
const { appIcon } = useAppIcon(app)
|
||||
|
||||
@@ -91,10 +41,10 @@ const licenseText = computed(() => {
|
||||
if (!app.value) {
|
||||
return ''
|
||||
}
|
||||
if (app.value.license !== '') {
|
||||
return t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() })
|
||||
if (app.value.licence) {
|
||||
return t('appstore', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() })
|
||||
}
|
||||
return t('settings', 'Version {version}', { version: app.value.version })
|
||||
return t('appstore', 'Version {version}', { version: app.value.version })
|
||||
})
|
||||
|
||||
const activeTab = ref('details')
|
||||
@@ -106,9 +56,12 @@ watch([app], () => {
|
||||
* Hide the details sidebar by pushing a new route
|
||||
*/
|
||||
function hideAppDetails() {
|
||||
router.push({
|
||||
name: 'apps-category',
|
||||
params: { category: route.params.category },
|
||||
router.replace({
|
||||
name: route.name!,
|
||||
params: {
|
||||
...route.params,
|
||||
id: undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +87,47 @@ watch([app], loadScreenshot)
|
||||
onMounted(loadScreenshot)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Selected app details -->
|
||||
<NcAppSidebar
|
||||
v-model:active="activeTab"
|
||||
class="app-sidebar"
|
||||
:class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
|
||||
:background="hasScreenshot ? app!.screenshot : undefined"
|
||||
:compact="!hasScreenshot"
|
||||
:name="app?.name ?? appId"
|
||||
:title="app?.name ?? appId"
|
||||
:subname="licenseText"
|
||||
:subtitle="licenseText"
|
||||
@close="hideAppDetails">
|
||||
<!-- Fallback icon in case no app icon is available -->
|
||||
<template v-if="!hasScreenshot" #header>
|
||||
<NcIconSvgWrapper
|
||||
class="app-sidebar__fallback-icon"
|
||||
:svg="appIcon ?? ''"
|
||||
:size="64" />
|
||||
</template>
|
||||
|
||||
<template v-if="app" #description>
|
||||
<!-- Featured/Supported badges -->
|
||||
<div class="app-sidebar__badges">
|
||||
<AppLevelBadge :level="app.level" />
|
||||
<AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<AppScore v-if="hasRating" :score="rating" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab content -->
|
||||
<NcEmptyContent v-if="!app" name="No such app" />
|
||||
<template v-else>
|
||||
<!-- <AppDescriptionTab :app="app" />
|
||||
<AppDetailsTab :app="app" />
|
||||
<AppReleasesTab :app="app" />
|
||||
<AppDeployDaemonTab :app="app" /> -->
|
||||
</template>
|
||||
</NcAppSidebar>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-sidebar {
|
||||
// If a screenshot is available it should cover the whole figure
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Appstore\Tests\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\App\AppStore\Fetcher\AppFetcher;
|
||||
use OC\App\AppStore\Fetcher\CategoryFetcher;
|
||||
use OC\App\DependencyAnalyzer;
|
||||
use OC\Installer;
|
||||
use OCA\Appstore\Controller\ApiController;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Support\Subscription\IRegistry;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Test\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
class ApiControllerTest extends TestCase {
|
||||
private IRequest&MockObject $request;
|
||||
private IConfig&MockObject $config;
|
||||
private IAppConfig&MockObject $appConfig;
|
||||
private AppManager&MockObject $appManager;
|
||||
private DependencyAnalyzer&MockObject $dependencyAnalyzer;
|
||||
private CategoryFetcher&MockObject $categoryFetcher;
|
||||
private AppFetcher&MockObject $appFetcher;
|
||||
private IFactory&MockObject $l10nFactory;
|
||||
private BundleFetcher&MockObject $bundleFetcher;
|
||||
private Installer&MockObject $installer;
|
||||
private IRegistry&MockObject $subscriptionRegistry;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
private ApiController $apiController;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->appConfig = $this->createMock(IAppConfig::class);
|
||||
$this->appManager = $this->createMock(AppManager::class);
|
||||
$this->dependencyAnalyzer = $this->createMock(DependencyAnalyzer::class);
|
||||
$this->categoryFetcher = $this->createMock(CategoryFetcher::class);
|
||||
$this->appFetcher = $this->createMock(AppFetcher::class);
|
||||
$this->l10nFactory = $this->createMock(IFactory::class);
|
||||
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
|
||||
$this->installer = $this->createMock(Installer::class);
|
||||
$this->subscriptionRegistry = $this->createMock(IRegistry::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->apiController = new ApiController(
|
||||
$this->request,
|
||||
$this->config,
|
||||
$this->appConfig,
|
||||
$this->appManager,
|
||||
$this->dependencyAnalyzer,
|
||||
$this->categoryFetcher,
|
||||
$this->appFetcher,
|
||||
$this->l10nFactory,
|
||||
$this->bundleFetcher,
|
||||
$this->installer,
|
||||
$this->subscriptionRegistry,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
public function testListCategories(): void {
|
||||
$json = file_get_contents(__DIR__ . '/../fixtures/categories.json');
|
||||
$this->categoryFetcher
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn(json_decode($json, true)['data']);
|
||||
|
||||
$response = $this->apiController->listCategories();
|
||||
$this->assertInstanceOf(DataResponse::class, $response);
|
||||
$this->assertSame(200, $response->getStatus());
|
||||
|
||||
$jsonResponse = json_encode($response->getData());
|
||||
$this->assertJsonStringEqualsJsonFile(__DIR__ . '/../fixtures/categories-api-response.json', $jsonResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Appstore\Tests\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\Installer;
|
||||
use OCA\Appstore\Controller\PageController;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Test\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\Group('DB')]
|
||||
class PageControllerTest extends TestCase {
|
||||
private IRequest&MockObject $request;
|
||||
private IL10N&MockObject $l10n;
|
||||
private IConfig&MockObject $config;
|
||||
private INavigationManager&MockObject $navigationManager;
|
||||
private AppManager&MockObject $appManager;
|
||||
private BundleFetcher&MockObject $bundleFetcher;
|
||||
private Installer&MockObject $installer;
|
||||
private IURLGenerator&MockObject $urlGenerator;
|
||||
private IInitialState&MockObject $initialState;
|
||||
private PageController $pageController;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->l10n = $this->createMock(IL10N::class);
|
||||
$this->l10n->expects($this->any())
|
||||
->method('t')
|
||||
->willReturnArgument(0);
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->navigationManager = $this->createMock(INavigationManager::class);
|
||||
$this->appManager = $this->createMock(AppManager::class);
|
||||
$this->bundleFetcher = $this->createMock(BundleFetcher::class);
|
||||
$this->installer = $this->createMock(Installer::class);
|
||||
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
||||
$this->initialState = $this->createMock(IInitialState::class);
|
||||
|
||||
$this->pageController = new PageController(
|
||||
$this->request,
|
||||
$this->l10n,
|
||||
$this->config,
|
||||
$this->installer,
|
||||
$this->appManager,
|
||||
$this->urlGenerator,
|
||||
$this->initialState,
|
||||
$this->bundleFetcher,
|
||||
$this->navigationManager,
|
||||
);
|
||||
}
|
||||
|
||||
public function testViewApps(): void {
|
||||
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
|
||||
$this->installer->expects($this->any())
|
||||
->method('isUpdateAvailable')
|
||||
->willReturn(false);
|
||||
$this->config
|
||||
->expects($this->once())
|
||||
->method('getSystemValueBool')
|
||||
->with('appstoreenabled', true)
|
||||
->willReturn(true);
|
||||
$this->navigationManager
|
||||
->expects($this->once())
|
||||
->method('setActiveEntry')
|
||||
->with('core_apps');
|
||||
|
||||
$this->initialState
|
||||
->expects($this->exactly(4))
|
||||
->method('provideInitialState');
|
||||
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
|
||||
|
||||
$expected = new TemplateResponse('appstore',
|
||||
'empty',
|
||||
[
|
||||
'pageTitle' => 'App store'
|
||||
],
|
||||
'user');
|
||||
$expected->setContentSecurityPolicy($policy);
|
||||
|
||||
$this->assertEquals($expected, $this->pageController->viewApps());
|
||||
}
|
||||
|
||||
public function testViewAppsAppstoreNotEnabled(): void {
|
||||
$this->installer->expects($this->any())
|
||||
->method('isUpdateAvailable')
|
||||
->willReturn(false);
|
||||
$this->bundleFetcher->expects($this->once())->method('getBundles')->willReturn([]);
|
||||
$this->config
|
||||
->expects($this->once())
|
||||
->method('getSystemValueBool')
|
||||
->with('appstoreenabled', true)
|
||||
->willReturn(false);
|
||||
$this->navigationManager
|
||||
->expects($this->once())
|
||||
->method('setActiveEntry')
|
||||
->with('core_apps');
|
||||
|
||||
$this->initialState
|
||||
->expects($this->exactly(4))
|
||||
->method('provideInitialState');
|
||||
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
|
||||
|
||||
$expected = new TemplateResponse('appstore',
|
||||
'empty',
|
||||
[
|
||||
'pageTitle' => 'App store'
|
||||
],
|
||||
'user');
|
||||
$expected->setContentSecurityPolicy($policy);
|
||||
|
||||
$this->assertEquals($expected, $this->pageController->viewApps());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"displayName": "AI",
|
||||
"id": "ai"
|
||||
},
|
||||
{
|
||||
"displayName": "Customization",
|
||||
"id": "customization"
|
||||
},
|
||||
{
|
||||
"displayName": "Dashboard",
|
||||
"id": "dashboard"
|
||||
},
|
||||
{
|
||||
"displayName": "Files",
|
||||
"id": "files"
|
||||
},
|
||||
{
|
||||
"displayName": "Games",
|
||||
"id": "games"
|
||||
},
|
||||
{
|
||||
"displayName": "Integration",
|
||||
"id": "integration"
|
||||
},
|
||||
{
|
||||
"displayName": "Monitoring",
|
||||
"id": "monitoring"
|
||||
},
|
||||
{
|
||||
"displayName": "Multimedia",
|
||||
"id": "multimedia"
|
||||
},
|
||||
{
|
||||
"displayName": "Office & text",
|
||||
"id": "office"
|
||||
},
|
||||
{
|
||||
"displayName": "Organization",
|
||||
"id": "organization"
|
||||
},
|
||||
{
|
||||
"displayName": "Search",
|
||||
"id": "search"
|
||||
},
|
||||
{
|
||||
"displayName": "Security",
|
||||
"id": "security"
|
||||
},
|
||||
{
|
||||
"displayName": "Social & communication",
|
||||
"id": "social"
|
||||
},
|
||||
{
|
||||
"displayName": "Tools",
|
||||
"id": "tools"
|
||||
},
|
||||
{
|
||||
"displayName": "Flow",
|
||||
"id": "workflow"
|
||||
}
|
||||
]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "ai",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "AI",
|
||||
"description": "Artificial Intelligence apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "AI",
|
||||
"description": "Apps für künstliche Intelligenz"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "customization",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Customization",
|
||||
"description": "Themes, layout and UX change apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Anpassung",
|
||||
"description": "Apps zur Änderung von Design, Layout und Benutzererfahrung"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dashboard",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Dashboard",
|
||||
"description": "Apps including Nextcloud Dashboard widgets"
|
||||
},
|
||||
"de": {
|
||||
"name": "Dashboard",
|
||||
"description": "Apps einschließlich Nextcloud Dashboard-Widgets"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Files",
|
||||
"description": "File management and Files app extension apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Dateien",
|
||||
"description": "Dateimanagement sowie Erweiterungs-Apps für die Dateien-App"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "games",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Games",
|
||||
"description": "Games run in your Nextcloud"
|
||||
},
|
||||
"de": {
|
||||
"name": "Spiele",
|
||||
"description": "Spiele für deine Nextcloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "integration",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Integration",
|
||||
"description": "Apps that connect Nextcloud with other services and platforms"
|
||||
},
|
||||
"de": {
|
||||
"name": "Einbindung",
|
||||
"description": "Apps die Nextcloud mit anderen Diensten und Plattformen verbinden"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "monitoring",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Monitoring",
|
||||
"description": "Data statistics, system diagnostics and activity apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Überwachung",
|
||||
"description": "Datenstatistiken-, Systemdiagnose- und Aktivitäten-Apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "multimedia",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Multimedia",
|
||||
"description": "Audio, video and picture apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Multimedia",
|
||||
"description": "Audio-, Video- und Bilder-Apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "office",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Office & text",
|
||||
"description": "Office and text processing apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Büro & Text",
|
||||
"description": "Büro- und Textverarbeitungs-Apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "organization",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Organization",
|
||||
"description": "Time management, Todo list and calendar apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Organisation",
|
||||
"description": "Time management, Todo list and calendar apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "search",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Search",
|
||||
"description": "Search related apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Suche",
|
||||
"description": "Verwandte Apps durchsuchen"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "security",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Security",
|
||||
"description": "Apps that provide additional security mechanisms like authentication, authorization, encryption, etc."
|
||||
},
|
||||
"de": {
|
||||
"name": "Sicherheit",
|
||||
"description": "Apps die zusätzliche Sicherheitsmechanismen bereitstellen, wie z. B. Authentifizierung, Autorisierung, Verschlüsselung usw."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Social & communication",
|
||||
"description": "Messaging, contact management and social media apps"
|
||||
},
|
||||
"de": {
|
||||
"name": "Kommunikation",
|
||||
"description": "Nachrichten-, Kontaktverwaltungs- und Social-Media-Apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tools",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Tools",
|
||||
"description": "Everything else"
|
||||
},
|
||||
"de": {
|
||||
"name": "Werkzeuge",
|
||||
"description": "Alles Andere"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workflow",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "Flow",
|
||||
"description": "Apps for Nextcloud Flow"
|
||||
},
|
||||
"de": {
|
||||
"name": "Flow",
|
||||
"description": "Apps für Nextcloud Flow"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1770475258,
|
||||
"ncversion": "34.0.0.0",
|
||||
"ETag": "\"6986f131-6575\""
|
||||
}
|
||||
@@ -20,22 +20,6 @@ return [
|
||||
['name' => 'MailSettings#storeCredentials', 'url' => '/settings/admin/mailsettings/credentials', 'verb' => 'POST' , 'root' => ''],
|
||||
['name' => 'MailSettings#sendTestMail', 'url' => '/settings/admin/mailtest', 'verb' => 'POST' , 'root' => ''],
|
||||
|
||||
['name' => 'AppSettings#getAppDiscoverJSON', 'url' => '/settings/api/apps/discover', 'verb' => 'GET', 'root' => ''],
|
||||
['name' => 'AppSettings#getAppDiscoverMedia', 'url' => '/settings/api/apps/media', 'verb' => 'GET', 'root' => ''],
|
||||
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#enableApp', 'url' => '/settings/apps/enable/{appId}', 'verb' => 'POST' , 'root' => ''],
|
||||
['name' => 'AppSettings#enableApps', 'url' => '/settings/apps/enable', 'verb' => 'POST' , 'root' => ''],
|
||||
['name' => 'AppSettings#disableApp', 'url' => '/settings/apps/disable/{appId}', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#disableApps', 'url' => '/settings/apps/disable', 'verb' => 'POST' , 'root' => ''],
|
||||
['name' => 'AppSettings#updateApp', 'url' => '/settings/apps/update/{appId}', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#uninstallApp', 'url' => '/settings/apps/uninstall/{appId}', 'verb' => 'GET' , 'root' => ''],
|
||||
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}', 'verb' => 'GET', 'defaults' => ['category' => ''] , 'root' => ''],
|
||||
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps/{category}/{id}', 'verb' => 'GET', 'defaults' => ['category' => '', 'id' => ''] , 'root' => ''],
|
||||
['name' => 'AppSettings#force', 'url' => '/settings/apps/force', 'verb' => 'POST' , 'root' => ''],
|
||||
|
||||
['name' => 'Users#setDisplayName', 'url' => '/settings/users/{username}/displayName', 'verb' => 'POST' , 'root' => ''],
|
||||
['name' => 'Users#setEMailAddress', 'url' => '/settings/users/{id}/mailAddress', 'verb' => 'PUT' , 'root' => ''],
|
||||
['name' => 'Users#setUserSettings', 'url' => '/settings/users/{username}/settings', 'verb' => 'PUT' , 'root' => ''],
|
||||
|
||||
@@ -22,7 +22,6 @@ return array(
|
||||
'OCA\\Settings\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php',
|
||||
'OCA\\Settings\\Controller\\AISettingsController' => $baseDir . '/../lib/Controller/AISettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AdminSettingsController' => $baseDir . '/../lib/Controller/AdminSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AuthSettingsController' => $baseDir . '/../lib/Controller/AuthSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AuthorizedGroupController' => $baseDir . '/../lib/Controller/AuthorizedGroupController.php',
|
||||
'OCA\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/../lib/Controller/ChangePasswordController.php',
|
||||
@@ -48,7 +47,6 @@ return array(
|
||||
'OCA\\Settings\\Mailer\\NewUserMailHelper' => $baseDir . '/../lib/Mailer/NewUserMailHelper.php',
|
||||
'OCA\\Settings\\Middleware\\SubadminMiddleware' => $baseDir . '/../lib/Middleware/SubadminMiddleware.php',
|
||||
'OCA\\Settings\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\Settings\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
|
||||
'OCA\\Settings\\Search\\SectionSearch' => $baseDir . '/../lib/Search/SectionSearch.php',
|
||||
'OCA\\Settings\\Search\\UserSearch' => $baseDir . '/../lib/Search/UserSearch.php',
|
||||
'OCA\\Settings\\Sections\\Admin\\Additional' => $baseDir . '/../lib/Sections/Admin/Additional.php',
|
||||
|
||||
@@ -37,7 +37,6 @@ class ComposerStaticInitSettings
|
||||
'OCA\\Settings\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php',
|
||||
'OCA\\Settings\\Controller\\AISettingsController' => __DIR__ . '/..' . '/../lib/Controller/AISettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AdminSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AdminSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AuthSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AuthSettingsController.php',
|
||||
'OCA\\Settings\\Controller\\AuthorizedGroupController' => __DIR__ . '/..' . '/../lib/Controller/AuthorizedGroupController.php',
|
||||
'OCA\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/..' . '/../lib/Controller/ChangePasswordController.php',
|
||||
@@ -63,7 +62,6 @@ class ComposerStaticInitSettings
|
||||
'OCA\\Settings\\Mailer\\NewUserMailHelper' => __DIR__ . '/..' . '/../lib/Mailer/NewUserMailHelper.php',
|
||||
'OCA\\Settings\\Middleware\\SubadminMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SubadminMiddleware.php',
|
||||
'OCA\\Settings\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
|
||||
'OCA\\Settings\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
|
||||
'OCA\\Settings\\Search\\SectionSearch' => __DIR__ . '/..' . '/../lib/Search/SectionSearch.php',
|
||||
'OCA\\Settings\\Search\\UserSearch' => __DIR__ . '/..' . '/../lib/Search/UserSearch.php',
|
||||
'OCA\\Settings\\Sections\\Admin\\Additional' => __DIR__ . '/..' . '/../lib/Sections/Admin/Additional.php',
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
namespace OCA\Settings\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
|
||||
use OC\App\AppStore\Fetcher\AppFetcher;
|
||||
use OC\App\AppStore\Fetcher\CategoryFetcher;
|
||||
use OC\App\AppStore\Version\VersionParser;
|
||||
use OC\App\DependencyAnalyzer;
|
||||
use OC\Installer;
|
||||
use OCA\AppAPI\Service\ExAppsPageService;
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\NotFoundResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\Files\AppData\IAppDataFactory;
|
||||
use OCP\Files\IAppData;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\NotPermittedException;
|
||||
use OCP\Files\SimpleFS\ISimpleFile;
|
||||
use OCP\Files\SimpleFS\ISimpleFolder;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Security\RateLimiting\ILimiter;
|
||||
use OCP\Server;
|
||||
use OCP\Util;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
class AppSettingsController extends Controller {
|
||||
|
||||
/** @var array */
|
||||
private $allApps = [];
|
||||
|
||||
private IAppData $appData;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
IAppDataFactory $appDataFactory,
|
||||
private IL10N $l10n,
|
||||
private IConfig $config,
|
||||
private INavigationManager $navigationManager,
|
||||
private AppManager $appManager,
|
||||
private CategoryFetcher $categoryFetcher,
|
||||
private AppFetcher $appFetcher,
|
||||
private IFactory $l10nFactory,
|
||||
private IGroupManager $groupManager,
|
||||
private BundleFetcher $bundleFetcher,
|
||||
private Installer $installer,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private LoggerInterface $logger,
|
||||
private IInitialState $initialState,
|
||||
private AppDiscoverFetcher $discoverFetcher,
|
||||
private IClientService $clientService,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->appData = $appDataFactory->get('appstore');
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
|
||||
*
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
public function viewApps(): TemplateResponse {
|
||||
$this->navigationManager->setActiveEntry('core_apps');
|
||||
|
||||
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
|
||||
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
|
||||
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
|
||||
$this->initialState->provideInitialState('isAllInOne', filter_var(getenv('THIS_IS_AIO'), FILTER_VALIDATE_BOOL));
|
||||
|
||||
$groups = array_map(static fn (IGroup $group): array => [
|
||||
'id' => $group->getGID(),
|
||||
'name' => $group->getDisplayName(),
|
||||
], $this->groupManager->search('', 5));
|
||||
|
||||
$this->initialState->provideInitialState('usersSettings', [ 'systemGroups' => $groups]);
|
||||
|
||||
if ($this->appManager->isEnabledForAnyone('app_api')) {
|
||||
try {
|
||||
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
|
||||
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
|
||||
}
|
||||
}
|
||||
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
|
||||
|
||||
$templateResponse = new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
|
||||
$templateResponse->setContentSecurityPolicy($policy);
|
||||
|
||||
Util::addStyle('settings', 'settings');
|
||||
Util::addScript('settings', 'vue-settings-apps-users-management');
|
||||
|
||||
return $templateResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active entries for the app discover section
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
public function getAppDiscoverJSON(): JSONResponse {
|
||||
$data = $this->discoverFetcher->get(true);
|
||||
return new JSONResponse(array_values($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
|
||||
*
|
||||
* @param string $image
|
||||
* @throws \Exception
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): Response {
|
||||
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
|
||||
$etag = trim($getEtag, '"');
|
||||
|
||||
$folder = null;
|
||||
try {
|
||||
$folder = $this->appData->getFolder('app-discover-cache');
|
||||
$this->cleanUpImageCache($folder, $etag);
|
||||
} catch (\Throwable $e) {
|
||||
$folder = $this->appData->newFolder('app-discover-cache');
|
||||
}
|
||||
|
||||
// Get the current cache folder
|
||||
try {
|
||||
$folder = $folder->getFolder($etag);
|
||||
} catch (NotFoundException $e) {
|
||||
$folder = $folder->newFolder($etag);
|
||||
}
|
||||
|
||||
$info = pathinfo($fileName);
|
||||
$hashName = md5($fileName);
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
// Try to find the file
|
||||
$file = array_filter($allFiles, function (ISimpleFile $file) use ($hashName) {
|
||||
return str_starts_with($file->getName(), $hashName);
|
||||
});
|
||||
// Get the first entry
|
||||
$file = reset($file);
|
||||
// If not found request from Web
|
||||
if ($file === false) {
|
||||
$user = $session->getUser();
|
||||
// this route is not public thus we can assume a user is logged-in
|
||||
assert($user !== null);
|
||||
// Register a user request to throttle fetching external data
|
||||
// this will prevent using the server for DoS of other systems.
|
||||
$limiter->registerUserRequest(
|
||||
'settings-discover-media',
|
||||
// allow up to 24 media requests per hour
|
||||
// this should be a sane default when a completely new section is loaded
|
||||
// keep in mind browsers request all files from a source-set
|
||||
24,
|
||||
60 * 60,
|
||||
$user,
|
||||
);
|
||||
|
||||
if (!$this->checkCanDownloadMedia($fileName)) {
|
||||
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
|
||||
return new NotFoundResponse(Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->clientService->newClient();
|
||||
$fileResponse = $client->get($fileName);
|
||||
$contentType = $fileResponse->getHeader('Content-Type');
|
||||
$extension = $info['extension'] ?? '';
|
||||
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
|
||||
return new NotFoundResponse();
|
||||
}
|
||||
} else {
|
||||
// File was found so we can get the content type from the file name
|
||||
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
|
||||
}
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
|
||||
// cache for 7 days
|
||||
$response->cacheFor(604800, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function checkCanDownloadMedia(string $filename): bool {
|
||||
$urlInfo = parse_url($filename);
|
||||
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allowed hosts
|
||||
if ($urlInfo['host'] === 'nextcloud.com') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hosts that need further verification
|
||||
// Github is only allowed if from our organization
|
||||
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
|
||||
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove orphaned folders from the image cache that do not match the current etag
|
||||
* @param ISimpleFolder $folder The folder to clear
|
||||
* @param string $etag The etag (directory name) to keep
|
||||
*/
|
||||
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
|
||||
// Cleanup old cache folders
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
foreach ($allFiles as $dir) {
|
||||
try {
|
||||
if ($dir->getName() !== $etag) {
|
||||
$dir->delete();
|
||||
}
|
||||
} catch (NotPermittedException $e) {
|
||||
// ignore folder for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getAppsWithUpdates() {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
foreach ($apps as $key => $app) {
|
||||
$newVersion = $this->installer->isUpdateAvailable($app['id']);
|
||||
if ($newVersion === false) {
|
||||
unset($apps[$key]);
|
||||
}
|
||||
}
|
||||
return $apps;
|
||||
}
|
||||
|
||||
private function getBundles() {
|
||||
$result = [];
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
$result[] = [
|
||||
'name' => $bundle->getName(),
|
||||
'id' => $bundle->getIdentifier(),
|
||||
'appIdentifiers' => $bundle->getAppIdentifiers()
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available categories
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function listCategories(): JSONResponse {
|
||||
return new JSONResponse($this->getAllCategories());
|
||||
}
|
||||
|
||||
private function getAllCategories() {
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
|
||||
$categories = $this->categoryFetcher->get();
|
||||
return array_map(fn ($category) => [
|
||||
'id' => $category['id'],
|
||||
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
|
||||
], $categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert URL to proxied URL so CSP is no problem
|
||||
*/
|
||||
private function createProxyPreviewUrl(string $url): string {
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
|
||||
}
|
||||
|
||||
private function fetchApps() {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['installed'] = true;
|
||||
|
||||
if (isset($app['screenshot'][0])) {
|
||||
$appScreenshot = $app['screenshot'][0] ?? null;
|
||||
if (is_array($appScreenshot)) {
|
||||
// Screenshot with thumbnail
|
||||
$appScreenshot = $appScreenshot['@value'];
|
||||
}
|
||||
|
||||
$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
|
||||
}
|
||||
$this->allApps[$app['id']] = $app;
|
||||
}
|
||||
|
||||
$apps = $this->getAppsForCategory('');
|
||||
$supportedApps = $appClass->getSupportedApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['appstore'] = true;
|
||||
if (!array_key_exists($app['id'], $this->allApps)) {
|
||||
$this->allApps[$app['id']] = $app;
|
||||
} else {
|
||||
$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
|
||||
}
|
||||
|
||||
if (in_array($app['id'], $supportedApps)) {
|
||||
$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
|
||||
}
|
||||
}
|
||||
|
||||
// add bundle information
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
foreach ($bundle->getAppIdentifiers() as $identifier) {
|
||||
foreach ($this->allApps as &$app) {
|
||||
if ($app['id'] === $identifier) {
|
||||
$app['bundleIds'][] = $bundle->getIdentifier();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getAllApps() {
|
||||
return $this->allApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available apps in a category
|
||||
*
|
||||
* @return JSONResponse
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function listApps(): JSONResponse {
|
||||
$this->fetchApps();
|
||||
$apps = $this->getAllApps();
|
||||
|
||||
$dependencyAnalyzer = Server::get(DependencyAnalyzer::class);
|
||||
|
||||
$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
|
||||
if (!is_array($ignoreMaxApps)) {
|
||||
$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
|
||||
$ignoreMaxApps = [];
|
||||
}
|
||||
|
||||
// Extend existing app details
|
||||
$apps = array_map(function (array $appData) use ($dependencyAnalyzer, $ignoreMaxApps) {
|
||||
if (isset($appData['appstoreData'])) {
|
||||
$appstoreData = $appData['appstoreData'];
|
||||
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
|
||||
$appData['category'] = $appstoreData['categories'];
|
||||
$appData['releases'] = $appstoreData['releases'];
|
||||
}
|
||||
|
||||
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
|
||||
if ($newVersion) {
|
||||
$appData['update'] = $newVersion;
|
||||
}
|
||||
|
||||
// fix groups to be an array
|
||||
$groups = [];
|
||||
if (is_string($appData['groups'])) {
|
||||
$groups = json_decode($appData['groups']);
|
||||
// ensure 'groups' is an array
|
||||
if (!is_array($groups)) {
|
||||
$groups = [$groups];
|
||||
}
|
||||
}
|
||||
$appData['groups'] = $groups;
|
||||
$appData['canUnInstall'] = !$appData['active'] && $appData['removable'];
|
||||
|
||||
// fix licence vs license
|
||||
if (isset($appData['license']) && !isset($appData['licence'])) {
|
||||
$appData['licence'] = $appData['license'];
|
||||
}
|
||||
|
||||
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
|
||||
|
||||
// analyse dependencies
|
||||
$missing = $dependencyAnalyzer->analyze($appData, $ignoreMax);
|
||||
$appData['canInstall'] = empty($missing);
|
||||
$appData['missingDependencies'] = $missing;
|
||||
|
||||
$appData['missingMinOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
|
||||
$appData['missingMaxOwnCloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
|
||||
$appData['isCompatible'] = $dependencyAnalyzer->isMarkedCompatible($appData);
|
||||
|
||||
return $appData;
|
||||
}, $apps);
|
||||
|
||||
usort($apps, [$this, 'sortApps']);
|
||||
|
||||
return new JSONResponse(['apps' => $apps, 'status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps for a category from the app store
|
||||
*
|
||||
* @param string $requestedCategory
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getAppsForCategory($requestedCategory = ''): array {
|
||||
$versionParser = new VersionParser();
|
||||
$formattedApps = [];
|
||||
$apps = $this->appFetcher->get();
|
||||
foreach ($apps as $app) {
|
||||
// Skip all apps not in the requested category
|
||||
if ($requestedCategory !== '') {
|
||||
$isInCategory = false;
|
||||
foreach ($app['categories'] as $category) {
|
||||
if ($category === $requestedCategory) {
|
||||
$isInCategory = true;
|
||||
}
|
||||
}
|
||||
if (!$isInCategory) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
|
||||
continue;
|
||||
}
|
||||
$nextCloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
|
||||
$nextCloudVersionDependencies = [];
|
||||
if ($nextCloudVersion->getMinimumVersion() !== '') {
|
||||
$nextCloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextCloudVersion->getMinimumVersion();
|
||||
}
|
||||
if ($nextCloudVersion->getMaximumVersion() !== '') {
|
||||
$nextCloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextCloudVersion->getMaximumVersion();
|
||||
}
|
||||
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
|
||||
|
||||
try {
|
||||
$this->appManager->getAppPath($app['id']);
|
||||
$existsLocally = true;
|
||||
} catch (AppPathNotFoundException) {
|
||||
$existsLocally = false;
|
||||
}
|
||||
|
||||
$phpDependencies = [];
|
||||
if ($phpVersion->getMinimumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
|
||||
}
|
||||
if ($phpVersion->getMaximumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
|
||||
}
|
||||
if (isset($app['releases'][0]['minIntSize'])) {
|
||||
$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
|
||||
}
|
||||
$authors = '';
|
||||
foreach ($app['authors'] as $key => $author) {
|
||||
$authors .= $author['name'];
|
||||
if ($key !== count($app['authors']) - 1) {
|
||||
$authors .= ', ';
|
||||
}
|
||||
}
|
||||
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
$enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no');
|
||||
$groups = null;
|
||||
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
|
||||
$groups = $enabledValue;
|
||||
}
|
||||
|
||||
$currentVersion = '';
|
||||
if ($this->appManager->isEnabledForAnyone($app['id'])) {
|
||||
$currentVersion = $this->appManager->getAppVersion($app['id']);
|
||||
} else {
|
||||
$currentVersion = $app['releases'][0]['version'];
|
||||
}
|
||||
|
||||
$formattedApps[] = [
|
||||
'id' => $app['id'],
|
||||
'app_api' => false,
|
||||
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
|
||||
'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
|
||||
'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
|
||||
'license' => $app['releases'][0]['licenses'],
|
||||
'author' => $authors,
|
||||
'shipped' => $this->appManager->isShipped($app['id']),
|
||||
'version' => $currentVersion,
|
||||
'default_enable' => '',
|
||||
'types' => [],
|
||||
'documentation' => [
|
||||
'admin' => $app['adminDocs'],
|
||||
'user' => $app['userDocs'],
|
||||
'developer' => $app['developerDocs']
|
||||
],
|
||||
'website' => $app['website'],
|
||||
'bugs' => $app['issueTracker'],
|
||||
'detailpage' => $app['website'],
|
||||
'dependencies' => array_merge(
|
||||
$nextCloudVersionDependencies,
|
||||
$phpDependencies
|
||||
),
|
||||
'level' => ($app['isFeatured'] === true) ? 200 : 100,
|
||||
'missingMaxOwnCloudVersion' => false,
|
||||
'missingMinOwnCloudVersion' => false,
|
||||
'canInstall' => true,
|
||||
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
|
||||
'score' => $app['ratingOverall'],
|
||||
'ratingNumOverall' => $app['ratingNumOverall'],
|
||||
'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
|
||||
'removable' => $existsLocally,
|
||||
'active' => $this->appManager->isEnabledForUser($app['id']),
|
||||
'needsDownload' => !$existsLocally,
|
||||
'groups' => $groups,
|
||||
'fromAppStore' => true,
|
||||
'appstoreData' => $app,
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $appId
|
||||
* @param array $groups
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
public function enableApp(string $appId, array $groups = []): JSONResponse {
|
||||
return $this->enableApps([$appId], $groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable one or more apps
|
||||
*
|
||||
* apps will be enabled for specific groups only if $groups is defined
|
||||
*
|
||||
* @param array $appIds
|
||||
* @param array $groups
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
public function enableApps(array $appIds, array $groups = []): JSONResponse {
|
||||
try {
|
||||
$updateRequired = false;
|
||||
|
||||
foreach ($appIds as $appId) {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
|
||||
// Check if app is already downloaded
|
||||
if (!$this->installer->isDownloaded($appId)) {
|
||||
$this->installer->downloadApp($appId);
|
||||
}
|
||||
|
||||
$this->installer->installApp($appId);
|
||||
|
||||
if (count($groups) > 0) {
|
||||
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
|
||||
} else {
|
||||
$this->appManager->enableApp($appId);
|
||||
}
|
||||
$updateRequired = $updateRequired || $this->appManager->isUpgradeRequired($appId);
|
||||
}
|
||||
return new JSONResponse(['data' => ['update_required' => $updateRequired]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('could not enable apps', ['exception' => $e]);
|
||||
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private function getGroupList(array $groups) {
|
||||
$groupManager = Server::get(IGroupManager::class);
|
||||
$groupsList = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupItem = $groupManager->get($group);
|
||||
if ($groupItem instanceof IGroup) {
|
||||
$groupsList[] = $groupManager->get($group);
|
||||
}
|
||||
}
|
||||
return $groupsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $appId
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
public function disableApp(string $appId): JSONResponse {
|
||||
return $this->disableApps([$appId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $appIds
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
public function disableApps(array $appIds): JSONResponse {
|
||||
try {
|
||||
foreach ($appIds as $appId) {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->disableApp($appId);
|
||||
}
|
||||
return new JSONResponse([]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('could not disable app', ['exception' => $e]);
|
||||
return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $appId
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[PasswordConfirmationRequired]
|
||||
public function uninstallApp(string $appId): JSONResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$result = $this->installer->removeApp($appId);
|
||||
if ($result !== false) {
|
||||
// If this app was force enabled, remove the force-enabled-state
|
||||
$this->appManager->removeOverwriteNextcloudRequirement($appId);
|
||||
$this->appManager->clearAppsCache();
|
||||
return new JSONResponse(['data' => ['appid' => $appId]]);
|
||||
}
|
||||
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not remove app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $appId
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function updateApp(string $appId): JSONResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
|
||||
$this->config->setSystemValue('maintenance', true);
|
||||
try {
|
||||
$result = $this->installer->updateAppstoreApp($appId);
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
} catch (\Exception $ex) {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
return new JSONResponse(['data' => ['message' => $ex->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if ($result !== false) {
|
||||
return new JSONResponse(['data' => ['appid' => $appId]]);
|
||||
}
|
||||
return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update app.')]], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
private function sortApps($a, $b) {
|
||||
$a = (string)$a['name'];
|
||||
$b = (string)$b['name'];
|
||||
if ($a === $b) {
|
||||
return 0;
|
||||
}
|
||||
return ($a < $b) ? -1 : 1;
|
||||
}
|
||||
|
||||
public function force(string $appId): JSONResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->overwriteNextcloudRequirement($appId);
|
||||
return new JSONResponse();
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ class UsersController extends Controller {
|
||||
$this->initialState->provideInitialState('usersSettings', $serverData);
|
||||
|
||||
Util::addStyle('settings', 'settings');
|
||||
Util::addScript('settings', 'vue-settings-apps-users-management');
|
||||
Util::addScript('settings', 'vue-settings-users-management');
|
||||
|
||||
return new TemplateResponse('settings', 'settings/empty', ['pageTitle' => $this->l10n->t('Settings')]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user