OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
14
app/Domain/Plugins/Contracts/PluginDisplayStrategy.php
Normal file
14
app/Domain/Plugins/Contracts/PluginDisplayStrategy.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Contracts;
|
||||
|
||||
interface PluginDisplayStrategy
|
||||
{
|
||||
public function getCardDesc(): string;
|
||||
|
||||
public function getPluginImageData(): string;
|
||||
|
||||
public function getMetadataLinks(): array;
|
||||
|
||||
public function getControlsView(): string;
|
||||
}
|
||||
46
app/Domain/Plugins/Contracts/PluginInterface.php
Normal file
46
app/Domain/Plugins/Contracts/PluginInterface.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Contracts;
|
||||
|
||||
/**
|
||||
* Interface PluginInterface
|
||||
*
|
||||
* This interface represents a plugin that can be installed, uninstalled, enabled, and disabled.
|
||||
*/
|
||||
interface PluginInterface
|
||||
{
|
||||
/**
|
||||
* Installs the plugin.
|
||||
*
|
||||
* @return bool True if the installation is successful, false otherwise.
|
||||
*/
|
||||
public function install(): bool;
|
||||
|
||||
/**
|
||||
* Uninstalls the plugin.
|
||||
*
|
||||
* This method performs the necessary actions to uninstall the application and remove all associated files and data.
|
||||
*
|
||||
* @return bool Returns true if the uninstallation is successful, false otherwise.
|
||||
*/
|
||||
public function uninstall(): bool;
|
||||
|
||||
/**
|
||||
* Enables the plugin.
|
||||
*
|
||||
* This method performs the necessary actions to enable the specified functionality. It may update configuration settings, start background processes, or perform any other actions required
|
||||
* to enable the functionality.
|
||||
*
|
||||
* @return bool Returns true if the enable operation is successful, false otherwise.
|
||||
*/
|
||||
public function enable(): bool;
|
||||
|
||||
/**
|
||||
* Disable the plugin.
|
||||
*
|
||||
* This method disables the functionality and returns a boolean value indicating whether the functionality is successfully disabled or not.
|
||||
*
|
||||
* @return bool True if the functionality is successfully disabled, false otherwise.
|
||||
*/
|
||||
public function disable(): bool;
|
||||
}
|
||||
28
app/Domain/Plugins/Controllers/CssLoader.php
Normal file
28
app/Domain/Plugins/Controllers/CssLoader.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CssLoader extends Controller
|
||||
{
|
||||
private PluginService $pluginService;
|
||||
|
||||
public function init(PluginService $pluginService): void
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin]);
|
||||
$this->pluginService = $pluginService;
|
||||
}
|
||||
|
||||
public function get(): Response
|
||||
{
|
||||
$response = new Response($this->pluginService->getAggregatedPluginCss());
|
||||
$response->headers->set('Content-Type', 'text/css');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
45
app/Domain/Plugins/Controllers/Details.php
Normal file
45
app/Domain/Plugins/Controllers/Details.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Details extends Controller
|
||||
{
|
||||
private PluginService $pluginService;
|
||||
|
||||
public function init(PluginService $pluginService): void
|
||||
{
|
||||
$this->pluginService = $pluginService;
|
||||
}
|
||||
|
||||
public function get(): Response
|
||||
{
|
||||
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
if (! $this->incomingRequest->query->has('id')) {
|
||||
throw new \Exception('Plugin Identifier is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var \Leantime\Domain\Plugins\Models\MarketplacePlugin|false $plugin
|
||||
*/
|
||||
$plugin = $this->pluginService->getMarketplacePlugin(
|
||||
$this->incomingRequest->query->get('id'),
|
||||
);
|
||||
|
||||
if (! $plugin) {
|
||||
return $this->tpl->display('errors.error404', 'blank');
|
||||
}
|
||||
|
||||
$this->tpl->assign('isBundle', $this->pluginService->isBundle($plugin));
|
||||
$this->tpl->assign('plugin', $plugin);
|
||||
|
||||
return $this->tpl->display('plugins.plugindetails', 'blank');
|
||||
}
|
||||
}
|
||||
21
app/Domain/Plugins/Controllers/Marketplace.php
Normal file
21
app/Domain/Plugins/Controllers/Marketplace.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Marketplace extends Controller
|
||||
{
|
||||
public function get(): Response
|
||||
{
|
||||
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
|
||||
$this->tpl->assign('plugins', []);
|
||||
|
||||
return $this->tpl->display('plugins.marketplace');
|
||||
}
|
||||
}
|
||||
54
app/Domain/Plugins/Controllers/Myapps.php
Normal file
54
app/Domain/Plugins/Controllers/Myapps.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Myapps extends Controller
|
||||
{
|
||||
private PluginService $pluginService;
|
||||
|
||||
public function init(PluginService $pluginService): void
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin], true);
|
||||
$this->pluginService = $pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function get(): Response
|
||||
{
|
||||
foreach (['install', 'enable', 'disable', 'remove'] as $action) {
|
||||
$id = $this->incomingRequest->query->get($action);
|
||||
|
||||
if (empty($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->tpl->setNotification(...$this->pluginService->performPluginAction($action, $id));
|
||||
} catch (\Exception $e) {
|
||||
$this->tpl->setNotification($e->getMessage(), 'error');
|
||||
}
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/plugins/myapps');
|
||||
}
|
||||
|
||||
$this->tpl->assign('newPlugins', $this->pluginService->discoverNewPlugins());
|
||||
$this->tpl->assign('installedPlugins', $this->pluginService->getAllPlugins());
|
||||
|
||||
return $this->tpl->display('plugins.myapps');
|
||||
}
|
||||
|
||||
public function post($params): Response
|
||||
{
|
||||
return Frontcontroller::redirect(BASE_URL.'/plugins/myapps');
|
||||
}
|
||||
}
|
||||
0
app/Domain/Plugins/Controllers/Show.php
Normal file
0
app/Domain/Plugins/Controllers/Show.php
Normal file
59
app/Domain/Plugins/Hxcontrollers/Details.php
Normal file
59
app/Domain/Plugins/Hxcontrollers/Details.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Hxcontrollers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Plugins\Permissions\PluginsPermissions;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
|
||||
class Details extends HtmxController
|
||||
{
|
||||
protected static string $view = 'plugins::plugindetails';
|
||||
|
||||
private PluginService $pluginService;
|
||||
|
||||
public function init(
|
||||
PluginService $pluginService,
|
||||
): void {
|
||||
$this->pluginService = $pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
#[RequiresPermission(PluginsPermissions::MANAGE, global: true)]
|
||||
public function install(): string
|
||||
{
|
||||
$pluginProps = $this->incomingRequest->request->all()['plugin'];
|
||||
$version = $pluginProps['version'];
|
||||
unset($pluginProps['version']);
|
||||
|
||||
$pluginModel = $this->pluginService->buildMarketplacePluginFromRequest($pluginProps);
|
||||
|
||||
$this->tpl->assign('plugin', $pluginModel);
|
||||
$this->tpl->assign('isBundle', $this->pluginService->isBundle($pluginModel));
|
||||
|
||||
try {
|
||||
$this->pluginService->installMarketplacePlugin($pluginModel, $version);
|
||||
} catch (RequestException $e) {
|
||||
report($e);
|
||||
|
||||
$this->tpl->assign('formError', $this->pluginService->parseMarketplaceError($e));
|
||||
|
||||
return 'plugin-installation';
|
||||
}
|
||||
|
||||
if ($this->pluginService->isEnabled($pluginModel->identifier)) {
|
||||
$this->tpl->assign('formNotification', __('marketplace.updated'));
|
||||
|
||||
return 'plugin-installation';
|
||||
}
|
||||
|
||||
$this->tpl->assign('formNotification', sprintf(__('marketplace.installed'), '/plugins/myapps'));
|
||||
|
||||
return 'plugin-installation';
|
||||
}
|
||||
}
|
||||
50
app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php
Normal file
50
app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Hxcontrollers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Plugins\Models\MarketplacePlugin;
|
||||
use Leantime\Domain\Plugins\Services\Plugins as PluginService;
|
||||
|
||||
class Marketplaceplugins extends HtmxController
|
||||
{
|
||||
protected static string $view = 'plugins::partials.pluginlist';
|
||||
|
||||
private PluginService $pluginService;
|
||||
|
||||
public function init(
|
||||
PluginService $pluginService,
|
||||
): void {
|
||||
$this->pluginService = $pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function getlist(): void
|
||||
{
|
||||
/** @var MarketplacePlugin[] $plugins */
|
||||
$plugins = $this->pluginService->getMarketplacePlugins(
|
||||
$this->incomingRequest->query->get('page', 1),
|
||||
$this->incomingRequest->query->get('search', ''),
|
||||
);
|
||||
|
||||
$this->tpl->assign('plugins', $plugins);
|
||||
}
|
||||
|
||||
public function getLatest()
|
||||
{
|
||||
/** @var MarketplacePlugin[] $plugins */
|
||||
$plugins = $this->pluginService->getLatestPluginUpdates(
|
||||
$this->incomingRequest->query->get('page', 1),
|
||||
$this->incomingRequest->query->get('search', ''),
|
||||
);
|
||||
|
||||
$this->tpl->assign('plugins', $plugins);
|
||||
|
||||
return $this->tpl->displayPartial('plugins::partials.latestPlugins');
|
||||
}
|
||||
|
||||
public function search(): void {}
|
||||
}
|
||||
175
app/Domain/Plugins/Models/InstalledPlugin.php
Normal file
175
app/Domain/Plugins/Models/InstalledPlugin.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Models;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Domain\Plugins\Contracts\PluginDisplayStrategy;
|
||||
|
||||
class InstalledPlugin implements PluginDisplayStrategy
|
||||
{
|
||||
public ?int $id;
|
||||
|
||||
public string $name;
|
||||
|
||||
public bool $enabled;
|
||||
|
||||
public string $description;
|
||||
|
||||
public string $version;
|
||||
|
||||
public string $imageUrl = '';
|
||||
|
||||
public string $vendorDisplayName;
|
||||
|
||||
public int $vendorId;
|
||||
|
||||
public string $vendorEmail;
|
||||
|
||||
public string $installdate;
|
||||
|
||||
public string $foldername;
|
||||
|
||||
public string $homepage;
|
||||
|
||||
public string|array $authors;
|
||||
|
||||
public ?string $format;
|
||||
|
||||
public ?string $license;
|
||||
|
||||
public ?string $type;
|
||||
|
||||
public ?bool $installed;
|
||||
|
||||
public ?string $startingPrice;
|
||||
|
||||
public ?string $calculatedMonthlyPrice;
|
||||
|
||||
public ?string $identifier;
|
||||
|
||||
public function getCardDesc(): string
|
||||
{
|
||||
return $this->description ??= '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the metadata links for the plugin.
|
||||
*
|
||||
* The metadata links include author's email, author's name, plugin version, and homepage URL.
|
||||
* If the authors are not empty, the email of the first author is included as a link.
|
||||
* If the version is not empty, the plugin version is included as a link.
|
||||
* If the homepage is not empty, the homepage URL is included as a link.
|
||||
*
|
||||
* @return array An array of metadata links.
|
||||
*/
|
||||
public function getMetadataLinks(): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (! empty($this->vendorDisplayName) && (! empty($this->vendorId) || ! empty($this->vendorEmail))) {
|
||||
$vendor = [
|
||||
'prefix' => __('text.by'),
|
||||
'display' => $this->vendorDisplayName,
|
||||
];
|
||||
|
||||
$vendor['link'] = ! empty($this->vendorId) ? '/plugins/marketplace?'.http_build_query(['vendor_id' => $this->vendorId]) : "mailto:{$this->vendorEmail}";
|
||||
|
||||
$links[] = $vendor;
|
||||
}
|
||||
|
||||
if (! empty($this->authors) && is_array($this->authors)) {
|
||||
$author = $this->authors[0];
|
||||
|
||||
if (is_object($author)) {
|
||||
$links[] = [
|
||||
'prefix' => __('text.by'),
|
||||
'link' => "mailto:{$author->email}",
|
||||
'text' => $author->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($this->version)) {
|
||||
$links[] = [
|
||||
'prefix' => __('text.version'),
|
||||
'text' => $this->version,
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($this->homepage)) {
|
||||
$links[] = [
|
||||
'link' => $this->homepage,
|
||||
'text' => __('text.visit_site'),
|
||||
];
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
public function getControlsView(): string
|
||||
{
|
||||
return 'plugins::partials.installed.plugincontrols';
|
||||
}
|
||||
|
||||
public function getPluginImageData(): string
|
||||
{
|
||||
if (! empty($this->imageUrl) && $this->imageUrl != 'false') {
|
||||
return $this->imageUrl;
|
||||
}
|
||||
|
||||
if (file_exists($image = APP_ROOT.'/app/Plugins/'.str_replace('.', '', $this->foldername).'/screenshot.png')) {
|
||||
// Read image path, convert to base64 encoding
|
||||
$imageData = base64_encode(file_get_contents($image));
|
||||
|
||||
return 'data: '.mime_content_type($image).';base64,'.$imageData;
|
||||
}
|
||||
|
||||
$image = APP_ROOT.'/public/dist/images/svg/undraw_search_app_oso2.svg';
|
||||
$imageData = base64_encode(file_get_contents($image));
|
||||
|
||||
return 'data: '.mime_content_type($image).';base64,'.$imageData;
|
||||
}
|
||||
|
||||
public function getPrice(): string
|
||||
{
|
||||
if (! empty($this->startingPrice)) {
|
||||
return __('text.starting_at').' '.$this->startingPrice;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getCalulatedMonthlyPrice(): string
|
||||
{
|
||||
if (! empty($this->calculatedMonthlyPrice)) {
|
||||
return $this->calculatedMonthlyPrice;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
$this->type = $this->format === 'phar'
|
||||
? $this->type = 'marketplace'
|
||||
: $this->type = 'custom';
|
||||
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
if (isset($this->identifier) && $this->identifier !== null && $this->identifier !== '') {
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
// There are circumstances where name needs to be used. The root cause has been fixed and identifier should
|
||||
// be set most of the time however in rare circumstances we need to make sure the name is built correctly
|
||||
$name = Str::replace('/', '_', Str::lower($this->name));
|
||||
if (Str::contains($name, '_') === false) {
|
||||
$name = 'leantime_'.$name;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
123
app/Domain/Plugins/Models/MarketplacePlugin.php
Normal file
123
app/Domain/Plugins/Models/MarketplacePlugin.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Models;
|
||||
|
||||
use Leantime\Domain\Plugins\Contracts\PluginDisplayStrategy;
|
||||
|
||||
class MarketplacePlugin implements PluginDisplayStrategy
|
||||
{
|
||||
public ?string $identifier = '';
|
||||
|
||||
public ?string $name = '';
|
||||
|
||||
public ?string $excerpt = '';
|
||||
|
||||
public ?string $description = '';
|
||||
|
||||
public ?string $imageUrl = '';
|
||||
|
||||
public ?string $vendorDisplayName = '';
|
||||
|
||||
public int $vendorId = 0;
|
||||
|
||||
public ?string $vendorEmail = '';
|
||||
|
||||
public ?string $marketplaceUrl = '';
|
||||
|
||||
public ?string $startingPrice = null;
|
||||
|
||||
public ?string $calculatedMonthlyPrice = null;
|
||||
|
||||
public ?array $pricingTiers = null;
|
||||
|
||||
public ?string $license = null;
|
||||
|
||||
public ?string $rating = null;
|
||||
|
||||
public ?int $reviewCount = null;
|
||||
|
||||
public ?string $type = 'marketplace';
|
||||
|
||||
public array $reviews = [];
|
||||
|
||||
public ?string $marketplaceId = '';
|
||||
|
||||
public array $compatibility = [];
|
||||
|
||||
public ?string $version = '';
|
||||
|
||||
public ?string $icon = '';
|
||||
|
||||
public array $categories = [];
|
||||
|
||||
public array $tags = [];
|
||||
|
||||
public function getCardDesc(): string
|
||||
{
|
||||
return $this->excerpt;
|
||||
}
|
||||
|
||||
public function getMetadataLinks(): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (! empty($this->vendorDisplayName) && (! empty($this->vendorId) || ! empty($this->vendorEmail))) {
|
||||
$vendor = [
|
||||
'prefix' => __('text.by'),
|
||||
'display' => $this->vendorDisplayName,
|
||||
];
|
||||
|
||||
$vendor['link'] = ! empty($this->vendorId) ? '/plugins/marketplace?'.http_build_query(['vendor_id' => $this->vendorId]) : "mailto:{$this->vendorEmail}";
|
||||
|
||||
$links[] = $vendor;
|
||||
}
|
||||
|
||||
if (! empty($this->rating)) {
|
||||
$links[] = [
|
||||
'prefix' => __('text.rating'),
|
||||
'display' => $this->rating,
|
||||
];
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
public function getPrice(): string
|
||||
{
|
||||
if (! empty($this->startingPrice)) {
|
||||
return __('text.starting_at').' '.$this->startingPrice;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getCalulatedMonthlyPrice(): string
|
||||
{
|
||||
if (! empty($this->calculatedMonthlyPrice)) {
|
||||
return $this->calculatedMonthlyPrice;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getControlsView(): string
|
||||
{
|
||||
return 'plugins::partials.marketplace.plugincontrols';
|
||||
}
|
||||
|
||||
public function getPluginImageData(): string
|
||||
{
|
||||
static $defaultImage;
|
||||
$defaultImage ??= 'data: '
|
||||
.mime_content_type($imageUrl = APP_ROOT.'/public/dist/images/svg/undraw_search_app_oso2.svg')
|
||||
.';base64,'.base64_encode(file_get_contents($imageUrl));
|
||||
|
||||
return ! empty($this->imageUrl) ? $this->imageUrl : $defaultImage;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type = 'marketplace';
|
||||
|
||||
}
|
||||
}
|
||||
37
app/Domain/Plugins/Permissions/PluginsPermissions.php
Normal file
37
app/Domain/Plugins/Permissions/PluginsPermissions.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* The Plugins (extension management) permission vocabulary — the verbs only.
|
||||
*
|
||||
* Managing plugins (installing from the marketplace or a folder, enabling, disabling, updating,
|
||||
* removing, and discovering new ones) is an installation-wide administrative capability, not a
|
||||
* per-project one. So the single verb below is COMPANY-WIDE (`projectScoped = false`) and call
|
||||
* sites gate with `#[RequiresPermission(PluginsPermissions::MANAGE, global: true)]`, evaluated
|
||||
* against the user's GLOBAL role. By the default role map it lands on admin/owner only (the
|
||||
* `scope:any verbs:['*']` admin rule), matching the existing marketplace/my-apps UI gate.
|
||||
*
|
||||
* Reading which plugins are enabled (boot, menu, composers) is NOT gated by this — that is
|
||||
* internal plumbing every request needs and carries no `plugins.*` requirement.
|
||||
*/
|
||||
final class PluginsPermissions implements ProvidesPermissions
|
||||
{
|
||||
/** Install, enable, disable, update, remove, or discover plugins (company-wide). */
|
||||
public const MANAGE = 'plugins.manage';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'plugins';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::MANAGE, 'Manage plugins', false),
|
||||
];
|
||||
}
|
||||
}
|
||||
156
app/Domain/Plugins/Repositories/Plugins.php
Normal file
156
app/Domain/Plugins/Repositories/Plugins.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Domain\Plugins\Models\InstalledPlugin;
|
||||
|
||||
class Plugins
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
/**
|
||||
* __construct - get database connection
|
||||
*/
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<InstalledPlugin>|false
|
||||
*/
|
||||
public function getAllPlugins(bool $enabledOnly = true): false|array
|
||||
{
|
||||
$query = $this->db->table('zp_plugins')
|
||||
->select(
|
||||
'id',
|
||||
'name',
|
||||
'enabled',
|
||||
'description',
|
||||
'version',
|
||||
'installdate',
|
||||
'foldername',
|
||||
'homepage',
|
||||
'authors',
|
||||
'format',
|
||||
'license'
|
||||
);
|
||||
|
||||
if ($enabledOnly) {
|
||||
$query->where('enabled', true);
|
||||
}
|
||||
|
||||
$results = $query->groupBy(
|
||||
'id',
|
||||
'name',
|
||||
'enabled',
|
||||
'description',
|
||||
'version',
|
||||
'installdate',
|
||||
'foldername',
|
||||
'homepage',
|
||||
'authors',
|
||||
'format',
|
||||
'license'
|
||||
)->get();
|
||||
|
||||
$allPlugins = [];
|
||||
foreach ($results as $row) {
|
||||
$plugin = new InstalledPlugin;
|
||||
$plugin->id = $row->id;
|
||||
$plugin->name = $row->name;
|
||||
$plugin->enabled = $row->enabled;
|
||||
$plugin->description = $row->description;
|
||||
$plugin->version = $row->version;
|
||||
$plugin->installdate = $row->installdate;
|
||||
$plugin->foldername = $row->foldername;
|
||||
$plugin->homepage = $row->homepage;
|
||||
$plugin->authors = json_decode($row->authors);
|
||||
$plugin->format = $row->format;
|
||||
$plugin->license = $row->license;
|
||||
$allPlugins[] = $plugin;
|
||||
}
|
||||
|
||||
return $allPlugins;
|
||||
}
|
||||
|
||||
public function getPlugin(int $id): InstalledPlugin|false
|
||||
{
|
||||
$result = $this->db->table('zp_plugins')
|
||||
->select(
|
||||
'id',
|
||||
'name',
|
||||
'enabled',
|
||||
'description',
|
||||
'version',
|
||||
'installdate',
|
||||
'foldername',
|
||||
'homepage',
|
||||
'authors',
|
||||
'license',
|
||||
'format'
|
||||
)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($result === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$plugin = new InstalledPlugin;
|
||||
$plugin->id = $result->id;
|
||||
$plugin->name = $result->name;
|
||||
$plugin->enabled = $result->enabled;
|
||||
$plugin->description = $result->description;
|
||||
$plugin->version = $result->version;
|
||||
$plugin->installdate = $result->installdate;
|
||||
$plugin->foldername = $result->foldername;
|
||||
$plugin->homepage = $result->homepage;
|
||||
$plugin->authors = $result->authors;
|
||||
$plugin->license = $result->license;
|
||||
$plugin->format = $result->format;
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
public function addPlugin(InstalledPlugin $plugin): false|string
|
||||
{
|
||||
$id = $this->db->table('zp_plugins')->insertGetId([
|
||||
'name' => $plugin->name,
|
||||
'enabled' => $plugin->enabled,
|
||||
'description' => $plugin->description,
|
||||
'version' => $plugin->version,
|
||||
'installdate' => $plugin->installdate,
|
||||
'foldername' => $plugin->foldername,
|
||||
'homepage' => $plugin->homepage,
|
||||
'authors' => $plugin->authors,
|
||||
'license' => $plugin->license ?? '',
|
||||
'format' => $plugin->format ?? 'folder',
|
||||
]);
|
||||
|
||||
return (string) $id;
|
||||
}
|
||||
|
||||
public function enablePlugin(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_plugins')
|
||||
->where('id', $id)
|
||||
->update(['enabled' => 1]) > 0;
|
||||
}
|
||||
|
||||
public function disablePlugin(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_plugins')
|
||||
->where('id', $id)
|
||||
->update(['enabled' => 0]) > 0;
|
||||
}
|
||||
|
||||
public function removePlugin(int $id): bool
|
||||
{
|
||||
return $this->db->table('zp_plugins')
|
||||
->where('id', $id)
|
||||
->delete() > 0;
|
||||
}
|
||||
}
|
||||
1013
app/Domain/Plugins/Services/Plugins.php
Normal file
1013
app/Domain/Plugins/Services/Plugins.php
Normal file
File diff suppressed because it is too large
Load Diff
8
app/Domain/Plugins/Services/Premium.php
Normal file
8
app/Domain/Plugins/Services/Premium.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Services;
|
||||
|
||||
class Premium
|
||||
{
|
||||
//
|
||||
}
|
||||
302
app/Domain/Plugins/Services/Registration.php
Normal file
302
app/Domain/Plugins/Services/Registration.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Core\Language;
|
||||
|
||||
class Registration
|
||||
{
|
||||
// Plugin Id: folder name of the plugin
|
||||
private string $pluginId;
|
||||
|
||||
private bool $distFolderRegistered = false;
|
||||
|
||||
public function __construct(string $pluginId)
|
||||
{
|
||||
$this->pluginId = $pluginId;
|
||||
|
||||
$this->registerManifestFolder();
|
||||
}
|
||||
|
||||
public function registerMiddleware(array $middleware)
|
||||
{
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.core.middleware.loadplugins.handle.pluginsEvents',
|
||||
function (array $existing) use ($middleware) {
|
||||
return array_merge($existing, $middleware);
|
||||
}
|
||||
);
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.core.http.httpkernel.*.plugins_middleware',
|
||||
function (array $existing) use ($middleware) {
|
||||
return array_merge($existing, $middleware);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public function registerLanguageFiles(array $languages = [])
|
||||
{
|
||||
$pluginId = $this->pluginId;
|
||||
|
||||
if (empty($languages)) {
|
||||
$languages = $this->findLanguageFiles();
|
||||
}
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.core.middleware.loadplugins.handle.pluginsEvents', function () use ($languages) {
|
||||
|
||||
$language = app()->make(Language::class);
|
||||
$config = app()->make(Environment::class);
|
||||
$currentUserLanguage = session('usersettings.language');
|
||||
|
||||
// At this point in the stack localization has already determined user language and set up the core language
|
||||
// array in the language of the users choice. First we register english if it is in the array and then we
|
||||
// override with the user language
|
||||
if (in_array('en-US', $languages)) {
|
||||
$pluginLangArray = $this->loadPluginLanguage('en-US');
|
||||
$language->mergeLanguageArray($pluginLangArray);
|
||||
}
|
||||
|
||||
// Now check the user language and override if needed
|
||||
if (in_array($currentUserLanguage, $languages)) {
|
||||
$pluginLangArray = $this->loadPluginLanguage($currentUserLanguage);
|
||||
$language->mergeLanguageArray($pluginLangArray);
|
||||
}
|
||||
|
||||
}, 5);
|
||||
|
||||
}
|
||||
|
||||
private function findLanguageFiles(): array
|
||||
{
|
||||
$pluginPath = APP_ROOT.'/app/Plugins/';
|
||||
$languageDir = '/Language/';
|
||||
|
||||
// Check both possible locations for language files
|
||||
$pharPath = "phar://{$pluginPath}{$this->pluginId}/{$this->pluginId}.phar".$languageDir;
|
||||
$regularPath = "{$pluginPath}{$this->pluginId}".$languageDir;
|
||||
|
||||
$languageFiles = [];
|
||||
|
||||
// Check regular directory first
|
||||
if (is_dir($regularPath)) {
|
||||
$files = scandir($regularPath);
|
||||
foreach ($files as $file) {
|
||||
if (substr($file, -4) === '.ini') {
|
||||
$languageFiles[] = substr($file, 0, -4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check phar if no files found in regular directory
|
||||
if (empty($languageFiles) && file_exists($pharPath)) {
|
||||
$files = scandir($pharPath);
|
||||
foreach ($files as $file) {
|
||||
if (substr($file, -4) === '.ini') {
|
||||
$languageFiles[] = substr($file, 0, -4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ! empty($languageFiles) ? $languageFiles : [];
|
||||
|
||||
}
|
||||
|
||||
private function loadPluginLanguage($language): array|false
|
||||
{
|
||||
|
||||
if (Cache::store('installation')->has($this->pluginId.'.language.'.$language)) {
|
||||
return Cache::store('installation')->get($this->pluginId.'.language.'.$language);
|
||||
}
|
||||
|
||||
$pluginPath = APP_ROOT.'/app/Plugins/';
|
||||
|
||||
$pharPath = "phar://{$pluginPath}{$this->pluginId}/{$this->pluginId}.phar";
|
||||
$regularPath = "{$pluginPath}{$this->pluginId}";
|
||||
|
||||
$languagePath = "/Language/{$language}.ini";
|
||||
|
||||
// Check phar first
|
||||
if (file_exists($pharPath.$languagePath)) {
|
||||
$completeLanguagePath = $pharPath.$languagePath;
|
||||
} elseif (file_exists($regularPath.$languagePath)) {
|
||||
$completeLanguagePath = $regularPath.$languagePath;
|
||||
} else {
|
||||
// Language file doesn't exist
|
||||
Cache::store('installation')->set($this->pluginId.'.language.'.$language, false, new \DateInterval('P7D'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$languageArray = parse_ini_file($completeLanguagePath, true);
|
||||
|
||||
// We're caching the results no matter what, language file is not going to magically appear.
|
||||
// So even a false is valid as parse_ini_is too expensive to run every time
|
||||
Cache::store('installation')->set($this->pluginId.'.language.'.$language, $languageArray, new \DateInterval('P7D'));
|
||||
|
||||
return $languageArray;
|
||||
|
||||
}
|
||||
|
||||
private function getPluginBasePath()
|
||||
{
|
||||
|
||||
$pluginPath = APP_ROOT.'/app/Plugins/';
|
||||
|
||||
$pharPath = "phar://{$pluginPath}{$this->pluginId}/{$this->pluginId}.phar";
|
||||
$regularPath = "{$pluginPath}{$this->pluginId}";
|
||||
|
||||
if (file_exists($pharPath)) {
|
||||
return $pharPath;
|
||||
}
|
||||
|
||||
if (file_exists($regularPath)) {
|
||||
return $regularPath;
|
||||
}
|
||||
|
||||
return '/';
|
||||
|
||||
}
|
||||
|
||||
public function addMenuItem(array $item, string $section, array $location)
|
||||
{
|
||||
|
||||
$pluginId = $this->pluginId;
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getMenuStructure.menuStructures.'.$section,
|
||||
function ($menu) use ($item, $location, $pluginId) {
|
||||
|
||||
// Prepare
|
||||
$item['title'] = "<span class='".$item['icon']."'></span> ".__($item['title']);
|
||||
$item['tooltip'] = __($item['tooltip']);
|
||||
$item['type'] = 'item';
|
||||
$item['module'] = $pluginId;
|
||||
|
||||
$primaryLocationKey = $location[0] ?? $menu[count($menu)];
|
||||
|
||||
if (count($location) <= 1) {
|
||||
$menu[$primaryLocationKey] = $item;
|
||||
}
|
||||
|
||||
if (count($location) == 2) {
|
||||
$submenuLocationKey = $location[1] ?? $menu[$primaryLocationKey]['submenu'][count(
|
||||
$menu[$primaryLocationKey]['submenu']
|
||||
)];
|
||||
|
||||
$menu[$primaryLocationKey]['submenu'][$submenuLocationKey] = $item;
|
||||
|
||||
}
|
||||
|
||||
return $menu;
|
||||
},
|
||||
50
|
||||
);
|
||||
|
||||
EventDispatcher::add_filter_listener('leantime.domain.menu.repositories.menu.getSectionMenuType.menuSections',
|
||||
function ($routes) use ($section, $item) {
|
||||
|
||||
$route = str_replace('/', '.', $item['href']);
|
||||
|
||||
$array = explode('.', $route);
|
||||
if ($route[0] == '.') {
|
||||
array_shift($array);
|
||||
}
|
||||
$route = implode('.', $array);
|
||||
|
||||
return array_merge($routes, [$route => $section]);
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public function addHeaderJs(array $jsFiles)
|
||||
{
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.*.afterLinkTags', function () use ($jsFiles) {
|
||||
|
||||
$mix = app()->make(\Leantime\Core\Support\Mix::class);
|
||||
$basePath = $this->getPluginBasePath();
|
||||
|
||||
// Add jQuery UI for draggable and resizable functionality
|
||||
$files = $mix->getManifest()[$basePath.'/dist'];
|
||||
|
||||
foreach ($jsFiles as $jsFile) {
|
||||
if (isset($files[$jsFile])) {
|
||||
echo '<script src="'.BASE_URL.$files[$jsFile].'"></script>';
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
protected function registerManifestFolder()
|
||||
{
|
||||
|
||||
if ($this->distFolderRegistered === false) {
|
||||
|
||||
$distPath = '';
|
||||
$basePath = $this->getPluginBasePath();
|
||||
if (file_exists($basePath.'/dist')) {
|
||||
$distPath = $basePath.'/dist';
|
||||
}
|
||||
|
||||
if ($distPath !== '') {
|
||||
EventDispatcher::add_filter_listener(
|
||||
'leantime.core.support.mix.__construct.mix_manifest_directories',
|
||||
function (array $directories) use ($distPath) {
|
||||
return array_merge($directories, [$distPath]);
|
||||
}
|
||||
);
|
||||
|
||||
$this->distFolderRegistered = true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public function addFooterJs(array $paths)
|
||||
{
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.*.beforeBodyClose', function () use ($paths) {
|
||||
|
||||
$mix = app()->make(\Leantime\Core\Support\Mix::class);
|
||||
$basePath = $this->getPluginBasePath();
|
||||
|
||||
// Add jQuery UI for draggable and resizable functionality
|
||||
$files = $mix->getManifest()[$basePath.'/dist'];
|
||||
|
||||
foreach ($paths as $jsFile) {
|
||||
if (isset($files[$jsFile])) {
|
||||
echo '<script src="'.BASE_URL.$files[$jsFile].'"></script>';
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public function addCss(array $paths)
|
||||
{
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.*.afterLinkTags', function () use ($paths) {
|
||||
|
||||
$mix = app()->make(\Leantime\Core\Support\Mix::class);
|
||||
$basePath = $this->getPluginBasePath();
|
||||
|
||||
// Add jQuery UI for draggable and resizable functionality
|
||||
$files = $mix->getManifest()[$basePath.'/dist'];
|
||||
|
||||
foreach ($paths as $cssFile) {
|
||||
if (isset($files[$cssFile])) {
|
||||
echo '<link rel="stylesheet" href="'.BASE_URL.$files[$cssFile].'" />';
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
35
app/Domain/Plugins/Templates/marketplace.blade.php
Normal file
35
app/Domain/Plugins/Templates/marketplace.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-global::pageheader :icon="'fa fa-puzzle-piece'">
|
||||
<h1>App Marketplace</h1>
|
||||
</x-global::pageheader>
|
||||
|
||||
@displayNotification()
|
||||
|
||||
<div class="maincontent">
|
||||
|
||||
@include('plugins::partials.plugintabs', ["currentUrl" => "marketplace"])
|
||||
|
||||
<div class="maincontentinner">
|
||||
|
||||
<div class="tw-w-full"
|
||||
hx-get="{{ BASE_URL }}/hx/plugins/marketplaceplugins/getlist"
|
||||
hx-trigger="load"
|
||||
hx-target="#pluginList"
|
||||
hx-indicator=".htmx-indicator, .htmx-loaded-content"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div id="pluginList">
|
||||
<div class="htmx-indicator tw-ml-m tw-mr-m tw-pt-l">
|
||||
<x-global::loadingText type="plugincard" count="5" includeHeadline="false"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
75
app/Domain/Plugins/Templates/myapps.blade.php
Normal file
75
app/Domain/Plugins/Templates/myapps.blade.php
Normal file
@@ -0,0 +1,75 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-global::pageheader :icon="'fa fa-puzzle-piece'">
|
||||
<h1>My Apps</h1>
|
||||
</x-global::pageheader>
|
||||
|
||||
@displayNotification()
|
||||
|
||||
<div class="maincontent">
|
||||
|
||||
@include('plugins::partials.plugintabs', ["currentUrl" => "installed"])
|
||||
|
||||
<div class="maincontentinner">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<h5 class="subtitle" style="margin-bottom:15px;">
|
||||
{{ __("text.installed_plugins") }}
|
||||
</h5>
|
||||
<div class="row sortableTicketList">
|
||||
@each('plugins::partials.plugin', $installedPlugins, 'plugin')
|
||||
|
||||
@if ($installedPlugins === false || count($installedPlugins) == 0)
|
||||
<span class="tw-block tw-px-4 tw-mb-4">{{ __("text.no_plugins_activated") }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<h5 class="subtitle tw-mb-m" style="margin-bottom:15px;">
|
||||
{{ __("text.new_plugins") }}
|
||||
</h5>
|
||||
<ul class="sortableTicketList" >
|
||||
@if (count($newPlugins) > 0)
|
||||
@foreach ($newPlugins as $newplugin)
|
||||
<li>
|
||||
<div class="ticketBox fixed">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-4">
|
||||
<strong>{{ $newplugin->name }}<br /></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ $newplugin->description }}<br />
|
||||
{{ $tpl->__("text.version") }} {{ $newplugin->version }}
|
||||
@if (is_array($newplugin->authors) && count($newplugin->authors) > 0)
|
||||
| {{ $tpl->__("text.by") }} <a href="mailto:{{ $newplugin->authors[0]["email"] }}">{{ $newplugin->authors[0]["name"] }}</a>
|
||||
@endif
|
||||
| <a href="{{ $newplugin->homepage }}"> {{ $tpl->__("text.visit_site") }} </a>
|
||||
</div>
|
||||
<div class="col-md-4" style="padding-top:5px;">
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/plugins/myapps?install={{ $newplugin->foldername }}" contentRole="default" class="pull-right">{{ $tpl->__('buttons.activate') }}</x-global::forms.button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
@else
|
||||
<x-global::undrawSvg image="undraw_empty_cart_co35.svg" headline="Nothing New">
|
||||
We couldn't discover any new plugins in your plugin folder, please make sure the plugin is unzipped and contains a composer.json file.
|
||||
</x-global::undrawSvg>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
@if($plugin->type !== "system")
|
||||
<div style="padding-top:10px; padding-left:0px;">
|
||||
@if (!$plugin->enabled)
|
||||
<a href="{{ BASE_URL }}/plugins/myapps?enable={{ $plugin->id }}" class=""><i class="fa-solid fa-plug-circle-check"></i> {{ __('buttons.enable') }}</a> |
|
||||
<a href="{{ BASE_URL }}/plugins/myapps?remove={{ $plugin->id }}" class="delete"><i class="fa fa-trash"></i> {{ __('buttons.remove') }}</a>
|
||||
@else
|
||||
<a href="{{ BASE_URL }}/plugins/myapps?disable={{ $plugin->id }}" class="delete"><i class="fa-solid fa-plug-circle-xmark"></i> {{ __('buttons.disable') }}</a>
|
||||
@endif
|
||||
|
||||
@if ($plugin->enabled && file_exists(APP_ROOT . '/app/Plugins/' . $plugin->foldername . '/Controllers/Settings.php'))
|
||||
<a href="{{ BASE_URL }}/{{ $plugin->foldername }}/settings"><i class="fa fa-cog"></i> Settings</a>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<p>System Plugin, cannot be disabled or removed</p>
|
||||
@endif
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
<h1>Latest Plugin Updates</h1>
|
||||
<p>Extend Leantime using our latest plugins.<br /><a href="{{ BASE_URL }}/plugins/marketplace"><i class="fa fa-cogs"></i> Manage Your Apps</a></p><br />
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<ul>
|
||||
|
||||
@foreach($plugins as $plugin)
|
||||
|
||||
<li onclick="window.location='#/plugins/details/{{ $plugin->identifier }}'">
|
||||
<img src="{{ $plugin->getPluginImageData() }}" width="75" height="75" class="tw-rounded tw-float-left tw-mr-m"/>
|
||||
@if (! empty($plugin->name))
|
||||
<a href="#/plugins/details/{{ $plugin->identifier }}"> <strong>{!! $plugin->name !!}</strong> {{ $plugin->version ? "(v".$plugin->version.")" : "" }}</a><br />
|
||||
<x-global::inlineLinks :links="$plugin->getMetadataLinks()" />
|
||||
@endif
|
||||
|
||||
@if (! empty($desc = $plugin->getCardDesc()))
|
||||
<p>{!! $desc !!}</p>
|
||||
@endif
|
||||
<div class="clearall"></div>
|
||||
<hr />
|
||||
|
||||
{{-- <div class="row tw-mb-base">--}}
|
||||
{{-- <div class="col tw-flex tw-flex-col tw-gap-base">--}}
|
||||
|
||||
|
||||
{{-- <div class="tw-flex tw-flex-row tw-gap-base">--}}
|
||||
{{-- <div class="plugin-price tw-flex-1 tw-content-center" >--}}
|
||||
{{-- <strong>{!! $plugin->getPrice() !!}</strong><br />--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="tw-border-t tw-border-[var(--main-border-color)] tw-px-base tw-text-right tw-flex-1 tw-justify-items-end">--}}
|
||||
{{-- @include($plugin->getControlsView(), ["plugin" => $plugin])--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
</li>
|
||||
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-gap-base">
|
||||
<x-global::button type="primary" link="#/plugins/details/{{ $plugin->identifier }}">
|
||||
{{ __('marketplace.details_link') }}
|
||||
</x-global::button>
|
||||
</div>
|
||||
47
app/Domain/Plugins/Templates/partials/plugin.blade.php
Normal file
47
app/Domain/Plugins/Templates/partials/plugin.blade.php
Normal file
@@ -0,0 +1,47 @@
|
||||
@props([
|
||||
'plugin'
|
||||
])
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="ticketBox fixed" style="padding-top:0px; overflow: hidden; margin-bottom: 25px;">
|
||||
<div class="row">
|
||||
<div class="col-md-12 tw-overflow-hidden tw-mb-m">
|
||||
<img src="{{ $plugin->getPluginImageData() }}" width="75" height="75" class="tw-rounded tw-mt-base"/>
|
||||
|
||||
@if($plugin instanceof \Leantime\Domain\Plugins\Models\MarketplacePlugin)
|
||||
<div
|
||||
class="certififed label-default tw-absolute tw-top-[10px] tw-right-[10px] tw-text-primary tw-rounded-full tw-text-sm"
|
||||
data-tippy-content="{{ __('marketplace.certified_tooltip') }}"
|
||||
>
|
||||
<i class="fa fa-certificate"></i>
|
||||
Certified
|
||||
</div>
|
||||
@endif
|
||||
<div class="clearall"></div>
|
||||
<div style="margin-top:10px;">
|
||||
@if (! empty($plugin->name))
|
||||
<strong style="font-size:var(--font-size-l);">{!! $plugin->name !!}</strong> {{ $plugin->version ? "(v".$plugin->version.")" : "" }}<br />
|
||||
<x-global::inlineLinks :links="$plugin->getMetadataLinks()" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row tw-mb-base">
|
||||
<div class="col tw-flex tw-flex-col tw-gap-base">
|
||||
|
||||
@if (! empty($desc = $plugin->getCardDesc()))
|
||||
<p>{!! $desc !!}</p>
|
||||
@endif
|
||||
<div class="tw-flex tw-flex-row tw-gap-base">
|
||||
<div class="plugin-price tw-flex-1 tw-content-center" >
|
||||
<strong>{!! $plugin->getPrice() !!}</strong><br />
|
||||
</div>
|
||||
<div class="tw-border-t tw-border-[var(--main-border-color)] tw-px-base tw-text-right tw-flex-1 tw-justify-items-end">
|
||||
@include($plugin->getControlsView(), ["plugin" => $plugin])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
33
app/Domain/Plugins/Templates/partials/pluginlist.blade.php
Normal file
33
app/Domain/Plugins/Templates/partials/pluginlist.blade.php
Normal file
@@ -0,0 +1,33 @@
|
||||
@props([
|
||||
'plugins'
|
||||
])
|
||||
|
||||
<div class="tw-w-full row">
|
||||
<div class="col-lg-12">
|
||||
<div class="row sortableTicketList">
|
||||
@if (count($plugins) == 0)
|
||||
<div class="tw-w-full htmx-loaded-content">
|
||||
<x-global::undrawSvg image="undraw_empty_cart_co35.svg" headline="Out of Stock">
|
||||
Due to a global bit shortage our plugins are currently out of stock. We are working hard to get more stock in as soon as possible.
|
||||
</x-global::undrawSvg>
|
||||
</div>
|
||||
@else
|
||||
|
||||
@foreach($plugins as $key => $pluginCategory)
|
||||
@if($key !== 'plugins')
|
||||
<div class="col-md-12">
|
||||
|
||||
<h1 style="border-bottom:1px solid rgba(0, 0, 0, 0.3); margin-bottom:10px; padding-bottom:10px;"><strong>{!! $pluginCategory['name'] !!}</strong></h1>
|
||||
<p style="">{!! $pluginCategory['description'] !!}</p> <br />
|
||||
|
||||
</div>
|
||||
@each('plugins::partials.plugin', $pluginCategory['plugins'], 'plugin')
|
||||
<div class="col-md-12" style="margin-bottom:20px;">
|
||||
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
19
app/Domain/Plugins/Templates/partials/plugintabs.blade.php
Normal file
19
app/Domain/Plugins/Templates/partials/plugintabs.blade.php
Normal file
@@ -0,0 +1,19 @@
|
||||
{{-- Standard floating tab nav (design call 2026-08-03) — the old
|
||||
.maincontentinner.tabs band CSS is gone, so this partial rides the
|
||||
shared lt-tabs structure like the ticket board tabs. --}}
|
||||
<div class="lt-tabs lt-tabs--floating lt-tabs--links hideOnPrint">
|
||||
<nav class="lt-tabs-group" aria-label="Apps">
|
||||
<ul>
|
||||
<li class="{{ $currentUrl == 'marketplace' ? "active" : "" }}">
|
||||
<a href="<?=BASE_URL ?>/plugins/marketplace">
|
||||
<i class="fa-solid fa-store"></i> Explore Apps
|
||||
</a>
|
||||
</li>
|
||||
<li class="{{ $currentUrl == 'installed' ? "active" : "" }}">
|
||||
<a href="<?=BASE_URL ?>/plugins/myapps">
|
||||
<i class="fa-solid fa-puzzle-piece"></i> My Apps
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
178
app/Domain/Plugins/Templates/plugindetails.blade.php
Normal file
178
app/Domain/Plugins/Templates/plugindetails.blade.php
Normal file
@@ -0,0 +1,178 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
<div class="tw-max-h-[90vh] tw-w-[40vw] tw-flex tw-flex-col tw-gap-base">
|
||||
<div class="tw-flex tw-gap-base tw-items-center">
|
||||
<img src="{{ $plugin->icon }}" width="175" height="175" class="tw-rounded">
|
||||
<div class="tw-flex tw-flex-col tw-gap-base">
|
||||
<h2 class="tw-text-2xl tw-flex tw-flex-col tw-gap-base">
|
||||
<span>{!! $plugin->name !!}</span>
|
||||
@if (!empty($plugin->vendorDisplayName) && !empty($plugin->vendorId))
|
||||
<small>{{ __('text.by') }} <a href="/plugins/marketplace?vendor_id={{ $plugin->vendorId }}">{{ $plugin->vendorDisplayName }}</a></small>
|
||||
@endif
|
||||
</h2>
|
||||
<p>
|
||||
@if ((int) $plugin->reviewCount > 0)
|
||||
<strong>Reviews:</strong> {{ $plugin->reviewCount }}<br>
|
||||
@endif
|
||||
|
||||
@if ((int) $plugin->rating > 0)
|
||||
<strong>Rating:</strong> {{ $plugin->rating }}<br>
|
||||
@endif
|
||||
|
||||
@if (! empty($plugin->categories))
|
||||
<strong>Categories:</strong> @foreach ($plugin->categories as $category)
|
||||
<x-global::badge :asLink="false">{{ $category['name'] }}</x-global::badge>
|
||||
@endforeach<br>
|
||||
@endif
|
||||
|
||||
@if (! empty($plugin->tags))
|
||||
<strong>Tags:</strong> @foreach ($plugin->tags as $tag)
|
||||
<x-global::badge :asLink="true" :url="'/plugins/marketplace?tag=' . $tag['slug']">{{ $tag['name'] }}</x-global::badge>
|
||||
@endforeach<br>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
// First visible tab is the initially-selected one (each section is conditional).
|
||||
$pluginTabOrder = array_values(array_filter([
|
||||
! empty($plugin->description) ? 'overview' : null,
|
||||
$plugin->reviewCount > 0 ? 'reviews' : null,
|
||||
! empty($plugin->compatibility) ? 'compatibility' : null,
|
||||
]));
|
||||
$pluginFirstTab = $pluginTabOrder[0] ?? null;
|
||||
@endphp
|
||||
@if ($pluginFirstTab !== null)
|
||||
<x-global::navigation.tabs group="plugindetails" label="Plugin details sections">
|
||||
@if (! empty($plugin->description))
|
||||
<x-global::navigation.tabs.tab name="overview" :selected="$pluginFirstTab === 'overview'">Overview</x-global::navigation.tabs.tab>
|
||||
@endif
|
||||
|
||||
@if ($plugin->reviewCount > 0)
|
||||
<x-global::navigation.tabs.tab name="reviews" :count="$plugin->reviewCount" :selected="$pluginFirstTab === 'reviews'">Reviews</x-global::navigation.tabs.tab>
|
||||
@endif
|
||||
|
||||
@if (! empty($plugin->compatibility))
|
||||
<x-global::navigation.tabs.tab name="compatibility" :selected="$pluginFirstTab === 'compatibility'">Compatibility</x-global::navigation.tabs.tab>
|
||||
@endif
|
||||
</x-global::navigation.tabs>
|
||||
@endif
|
||||
|
||||
<div class="tw-overflow-y-scroll tw-max-h-[600px]">
|
||||
@if (! empty($plugin->description))
|
||||
<x-global::navigation.tabs.panel name="overview" group="plugindetails">
|
||||
<div class="tw-pr-xs mce-content-body">{!! $plugin->description !!}</div>
|
||||
</x-global::navigation.tabs.panel>
|
||||
@endif
|
||||
|
||||
@if ($plugin->reviewCount > 0)
|
||||
<x-global::navigation.tabs.panel name="reviews" group="plugindetails">
|
||||
<div class="tw-flex tw-flex-col tw-gap-base">
|
||||
@foreach($plugin->reviews as $review)
|
||||
@if (is_array($review) || is_object($review))
|
||||
<div class="tw-border-b tw-border-gray-200 tw-pb-sm tw-mb-sm">
|
||||
@if (! empty($review['author'] ?? null))
|
||||
<strong>{{ $review['author'] }}</strong>
|
||||
@endif
|
||||
@if (! empty($review['rating'] ?? null))
|
||||
<span class="tw-text-sm tw-text-gray-500">({{ $review['rating'] }}/5)</span>
|
||||
@endif
|
||||
<p>{{ $review['content'] ?? $review['review'] ?? $review['text'] ?? '' }}</p>
|
||||
</div>
|
||||
@else
|
||||
<p>{{ $review }}</p>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</x-global::navigation.tabs.panel>
|
||||
@endif
|
||||
|
||||
@if (! empty($plugin->compatibility))
|
||||
<x-global::navigation.tabs.panel name="compatibility" group="plugindetails">
|
||||
<table class="tw-w-full tw-text-left tw-pt-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin Version:</th>
|
||||
<th>Compatible With Leantime Versions:</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($plugin->compatibility as $compatibility)
|
||||
<tr>
|
||||
<td>{{ $compatibility['version_number'] }}</td>
|
||||
<td>{{ $compatibility['supported_version_from'] }} - {{ $compatibility['supported_version_to'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-global::navigation.tabs.panel>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-justify-between tw-items-center">
|
||||
@if (! empty($plugin->marketplaceUrl))
|
||||
<x-global::button
|
||||
:link="$plugin->marketplaceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Get a license</x-global::button>
|
||||
@else
|
||||
<span>This plugin currently isn't available for purchase.</span>
|
||||
@endif
|
||||
|
||||
@fragment('plugin-installation')
|
||||
@if (! empty($plugin->marketplaceId))
|
||||
@if (isset($formNotification) && ! empty($formNotification))
|
||||
<div class="tw-text-green-500">{!! $formNotification !!}</div>
|
||||
@else
|
||||
<div id="installForm{{ $plugin->marketplaceId }}">
|
||||
|
||||
|
||||
@if (! empty($formError))
|
||||
<div class="tw-text-red-500">{!! $formError !!}</div>
|
||||
@endif
|
||||
|
||||
@if($isBundle === false)
|
||||
<form
|
||||
class="tw-flex tw-gap-2 tw-items-center"
|
||||
hx-post="{{ BASE_URL }}/hx/plugins/details/install"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator=".htmx-indicator-small, .htmx-loaded-content"
|
||||
hx-target="#installForm{{ $plugin->marketplaceId }}"
|
||||
>
|
||||
@php
|
||||
if (isset($plugin->version)) {
|
||||
unset($plugin->version);
|
||||
}
|
||||
@endphp
|
||||
@foreach ((array) $plugin as $prop => $value)
|
||||
<input type="hidden" name="plugin[{{ $prop }}]" value="{{ is_array($value) || is_object($value) ? json_encode($value) : $value }}" />
|
||||
@endforeach
|
||||
<select class="!tw-mb-none !tw-p-[4px]" name="plugin[version]">
|
||||
@foreach ($plugin->compatibility as $compatibility)
|
||||
<option value="{{ $compatibility['version_number'] }}">{{ $compatibility['version_number'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-global::forms.text-input class="!tw-mb-none !tw-p-[4px]" name="plugin[license]" placeholder="License Key" />
|
||||
<x-global::button
|
||||
:tag="'button'"
|
||||
:type="'secondary'"
|
||||
>Install</x-global::button>
|
||||
<div class="htmx-indicator-small">
|
||||
<x-global::loader id="loadingthis" size="25px" />
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<span>This plugin currently isn't available for installation.</span>
|
||||
@endif
|
||||
@endfragment
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
37
app/Domain/Plugins/register.php
Normal file
37
app/Domain/Plugins/register.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Plugins;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Leantime\Core\Events\EventDispatcher;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingsService;
|
||||
use Leantime\Domain\Users\Services\Users as UsersService;
|
||||
|
||||
EventDispatcher::add_event_listener('leantime.core.console.consolekernel.schedule.cron', function ($params) {
|
||||
|
||||
if (get_class($params['schedule']) !== Schedule::class) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params['schedule']->call(function () {
|
||||
/**
|
||||
* @var Services\Plugins $pluginsService
|
||||
**/
|
||||
$pluginsService = app()->make(Services\Plugins::class);
|
||||
|
||||
collect($pluginsService->getAllPlugins(true))
|
||||
->filter(fn ($plugin) => $plugin->type === 'marketplace')
|
||||
->filter(fn ($plugin) => $plugin->enabled)
|
||||
->each(function (Models\InstalledPlugin $plugin) use ($pluginsService) {
|
||||
static $instanceId, $numberOfUsers;
|
||||
$instanceId ??= app()->make(SettingsService::class)->getCompanyId();
|
||||
$numberOfUsers ??= app()->make(UsersService::class)->getNumberOfUsers(activeOnly: true, includeApi: false);
|
||||
|
||||
if ($pluginsService->validLicense($plugin) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pluginsService->disablePluginNotifyOwner($plugin->id);
|
||||
});
|
||||
})->name('plugins:checkLicense')->daily();
|
||||
});
|
||||
Reference in New Issue
Block a user