mirror of
https://github.com/nextcloud/richdocuments.git
synced 2025-12-18 05:20:43 +01:00
feat: Implement reference picker for document sections
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
parent
82e06d26f7
commit
a903a1db75
18 changed files with 782 additions and 5 deletions
|
|
@ -34,6 +34,7 @@ return [
|
|||
['name' => 'document#publicPage', 'url' => '/public', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'document#editOnline', 'url' => 'editonline', 'verb' => 'GET'],
|
||||
['name' => 'document#editOnlineTarget', 'url' => 'editonline/{fileId}/{target}', 'verb' => 'GET'],
|
||||
|
||||
// external api access
|
||||
['name' => 'document#extAppGetData', 'url' => '/ajax/extapp/data/{fileId}', 'verb' => 'POST'],
|
||||
|
|
@ -87,5 +88,8 @@ return [
|
|||
['name' => 'Federation#index', 'url' => '/api/v1/federation', 'verb' => 'GET'],
|
||||
['name' => 'Federation#remoteWopiToken', 'url' => '/api/v1/federation', 'verb' => 'POST'],
|
||||
['name' => 'Federation#initiatorUser', 'url' => '/api/v1/federation/user', 'verb' => 'POST'],
|
||||
|
||||
['name' => 'Target#getTargets', 'url' => '/api/v1/targets', 'verb' => 'GET'],
|
||||
['name' => 'Target#getPreview', 'url' => '/api/v1/targets/preview', 'verb' => 'GET'],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
"php": "8.0"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-master",
|
||||
"jakub-onderka/php-parallel-lint": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ return array(
|
|||
'OCA\\Richdocuments\\Controller\\FederationController' => $baseDir . '/../lib/Controller/FederationController.php',
|
||||
'OCA\\Richdocuments\\Controller\\OCSController' => $baseDir . '/../lib/Controller/OCSController.php',
|
||||
'OCA\\Richdocuments\\Controller\\SettingsController' => $baseDir . '/../lib/Controller/SettingsController.php',
|
||||
'OCA\\Richdocuments\\Controller\\TargetController' => $baseDir . '/../lib/Controller/TargetController.php',
|
||||
'OCA\\Richdocuments\\Controller\\TemplatesController' => $baseDir . '/../lib/Controller/TemplatesController.php',
|
||||
'OCA\\Richdocuments\\Controller\\WopiController' => $baseDir . '/../lib/Controller/WopiController.php',
|
||||
'OCA\\Richdocuments\\Db\\Asset' => $baseDir . '/../lib/Db/Asset.php',
|
||||
|
|
@ -40,6 +41,7 @@ return array(
|
|||
'OCA\\Richdocuments\\Listener\\CSPListener' => $baseDir . '/../lib/Listener/CSPListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\FileCreatedFromTemplateListener' => $baseDir . '/../lib/Listener/FileCreatedFromTemplateListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\LoadViewerListener' => $baseDir . '/../lib/Listener/LoadViewerListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\ReferenceListener' => $baseDir . '/../lib/Listener/ReferenceListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\ShareLinkListener' => $baseDir . '/../lib/Listener/ShareLinkListener.php',
|
||||
'OCA\\Richdocuments\\Middleware\\WOPIMiddleware' => $baseDir . '/../lib/Middleware/WOPIMiddleware.php',
|
||||
'OCA\\Richdocuments\\Migration\\Version2060Date20200302131958' => $baseDir . '/../lib/Migration/Version2060Date20200302131958.php',
|
||||
|
|
@ -55,11 +57,14 @@ return array(
|
|||
'OCA\\Richdocuments\\Preview\\Office' => $baseDir . '/../lib/Preview/Office.php',
|
||||
'OCA\\Richdocuments\\Preview\\OpenDocument' => $baseDir . '/../lib/Preview/OpenDocument.php',
|
||||
'OCA\\Richdocuments\\Preview\\Pdf' => $baseDir . '/../lib/Preview/Pdf.php',
|
||||
'OCA\\Richdocuments\\Reference\\OfficeTargetReferenceProvider' => $baseDir . '/../lib/Reference/OfficeTargetReferenceProvider.php',
|
||||
'OCA\\Richdocuments\\Service\\CapabilitiesService' => $baseDir . '/../lib/Service/CapabilitiesService.php',
|
||||
'OCA\\Richdocuments\\Service\\DemoService' => $baseDir . '/../lib/Service/DemoService.php',
|
||||
'OCA\\Richdocuments\\Service\\FederationService' => $baseDir . '/../lib/Service/FederationService.php',
|
||||
'OCA\\Richdocuments\\Service\\FileTargetService' => $baseDir . '/../lib/Service/FileTargetService.php',
|
||||
'OCA\\Richdocuments\\Service\\FontService' => $baseDir . '/../lib/Service/FontService.php',
|
||||
'OCA\\Richdocuments\\Service\\InitialStateService' => $baseDir . '/../lib/Service/InitialStateService.php',
|
||||
'OCA\\Richdocuments\\Service\\RemoteService' => $baseDir . '/../lib/Service/RemoteService.php',
|
||||
'OCA\\Richdocuments\\Service\\UserScopeService' => $baseDir . '/../lib/Service/UserScopeService.php',
|
||||
'OCA\\Richdocuments\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
|
||||
'OCA\\Richdocuments\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class ComposerStaticInitRichdocuments
|
|||
'OCA\\Richdocuments\\Controller\\FederationController' => __DIR__ . '/..' . '/../lib/Controller/FederationController.php',
|
||||
'OCA\\Richdocuments\\Controller\\OCSController' => __DIR__ . '/..' . '/../lib/Controller/OCSController.php',
|
||||
'OCA\\Richdocuments\\Controller\\SettingsController' => __DIR__ . '/..' . '/../lib/Controller/SettingsController.php',
|
||||
'OCA\\Richdocuments\\Controller\\TargetController' => __DIR__ . '/..' . '/../lib/Controller/TargetController.php',
|
||||
'OCA\\Richdocuments\\Controller\\TemplatesController' => __DIR__ . '/..' . '/../lib/Controller/TemplatesController.php',
|
||||
'OCA\\Richdocuments\\Controller\\WopiController' => __DIR__ . '/..' . '/../lib/Controller/WopiController.php',
|
||||
'OCA\\Richdocuments\\Db\\Asset' => __DIR__ . '/..' . '/../lib/Db/Asset.php',
|
||||
|
|
@ -55,6 +56,7 @@ class ComposerStaticInitRichdocuments
|
|||
'OCA\\Richdocuments\\Listener\\CSPListener' => __DIR__ . '/..' . '/../lib/Listener/CSPListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\FileCreatedFromTemplateListener' => __DIR__ . '/..' . '/../lib/Listener/FileCreatedFromTemplateListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\LoadViewerListener' => __DIR__ . '/..' . '/../lib/Listener/LoadViewerListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\ReferenceListener' => __DIR__ . '/..' . '/../lib/Listener/ReferenceListener.php',
|
||||
'OCA\\Richdocuments\\Listener\\ShareLinkListener' => __DIR__ . '/..' . '/../lib/Listener/ShareLinkListener.php',
|
||||
'OCA\\Richdocuments\\Middleware\\WOPIMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/WOPIMiddleware.php',
|
||||
'OCA\\Richdocuments\\Migration\\Version2060Date20200302131958' => __DIR__ . '/..' . '/../lib/Migration/Version2060Date20200302131958.php',
|
||||
|
|
@ -70,11 +72,14 @@ class ComposerStaticInitRichdocuments
|
|||
'OCA\\Richdocuments\\Preview\\Office' => __DIR__ . '/..' . '/../lib/Preview/Office.php',
|
||||
'OCA\\Richdocuments\\Preview\\OpenDocument' => __DIR__ . '/..' . '/../lib/Preview/OpenDocument.php',
|
||||
'OCA\\Richdocuments\\Preview\\Pdf' => __DIR__ . '/..' . '/../lib/Preview/Pdf.php',
|
||||
'OCA\\Richdocuments\\Reference\\OfficeTargetReferenceProvider' => __DIR__ . '/..' . '/../lib/Reference/OfficeTargetReferenceProvider.php',
|
||||
'OCA\\Richdocuments\\Service\\CapabilitiesService' => __DIR__ . '/..' . '/../lib/Service/CapabilitiesService.php',
|
||||
'OCA\\Richdocuments\\Service\\DemoService' => __DIR__ . '/..' . '/../lib/Service/DemoService.php',
|
||||
'OCA\\Richdocuments\\Service\\FederationService' => __DIR__ . '/..' . '/../lib/Service/FederationService.php',
|
||||
'OCA\\Richdocuments\\Service\\FileTargetService' => __DIR__ . '/..' . '/../lib/Service/FileTargetService.php',
|
||||
'OCA\\Richdocuments\\Service\\FontService' => __DIR__ . '/..' . '/../lib/Service/FontService.php',
|
||||
'OCA\\Richdocuments\\Service\\InitialStateService' => __DIR__ . '/..' . '/../lib/Service/InitialStateService.php',
|
||||
'OCA\\Richdocuments\\Service\\RemoteService' => __DIR__ . '/..' . '/../lib/Service/RemoteService.php',
|
||||
'OCA\\Richdocuments\\Service\\UserScopeService' => __DIR__ . '/..' . '/../lib/Service/UserScopeService.php',
|
||||
'OCA\\Richdocuments\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
|
||||
'OCA\\Richdocuments\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php',
|
||||
|
|
|
|||
|
|
@ -137,6 +137,10 @@ class AppConfig {
|
|||
return $this->config->getAppValue(Application::APPNAME, self::WOPI_URL, '');
|
||||
}
|
||||
|
||||
public function getDisableCertificateValidation(): bool {
|
||||
return $this->config->getAppValue(Application::APPNAME, 'disable_certificate_verification', 'no') === 'yes';
|
||||
}
|
||||
|
||||
public function getUseGroups(): ?array {
|
||||
$groups = $this->config->getAppValue(Application::APPNAME, 'use_groups', '');
|
||||
if ($groups === '') {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use OCA\Richdocuments\Listener\BeforeFetchPreviewListener;
|
|||
use OCA\Richdocuments\Listener\CSPListener;
|
||||
use OCA\Richdocuments\Listener\FileCreatedFromTemplateListener;
|
||||
use OCA\Richdocuments\Listener\LoadViewerListener;
|
||||
use OCA\Richdocuments\Listener\ReferenceListener;
|
||||
use OCA\Richdocuments\Listener\ShareLinkListener;
|
||||
use OCA\Richdocuments\Middleware\WOPIMiddleware;
|
||||
use OCA\Richdocuments\PermissionManager;
|
||||
|
|
@ -39,6 +40,7 @@ use OCA\Richdocuments\Preview\MSWord;
|
|||
use OCA\Richdocuments\Preview\OOXML;
|
||||
use OCA\Richdocuments\Preview\OpenDocument;
|
||||
use OCA\Richdocuments\Preview\Pdf;
|
||||
use OCA\Richdocuments\Reference\OfficeTargetReferenceProvider;
|
||||
use OCA\Richdocuments\Service\CapabilitiesService;
|
||||
use OCA\Richdocuments\Template\CollaboraTemplateProvider;
|
||||
use OCA\Richdocuments\WOPI\DiscoveryManager;
|
||||
|
|
@ -47,6 +49,7 @@ use OCP\AppFramework\App;
|
|||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\Collaboration\Reference\RenderReferenceEvent;
|
||||
use OCP\Files\Template\FileCreatedFromTemplateEvent;
|
||||
use OCP\Files\Template\ITemplateManager;
|
||||
use OCP\Files\Template\TemplateFileCreator;
|
||||
|
|
@ -73,6 +76,8 @@ class Application extends App implements IBootstrap {
|
|||
$context->registerEventListener(LoadViewer::class, LoadViewerListener::class);
|
||||
$context->registerEventListener(ShareLinkAccessedEvent::class, ShareLinkListener::class);
|
||||
$context->registerEventListener(BeforePreviewFetchedEvent::class, BeforeFetchPreviewListener::class);
|
||||
$context->registerEventListener(RenderReferenceEvent::class, ReferenceListener::class);
|
||||
$context->registerReferenceProvider(OfficeTargetReferenceProvider::class);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ use OCP\Share\IManager;
|
|||
class DocumentController extends Controller {
|
||||
use DocumentTrait;
|
||||
|
||||
public const SESSION_FILE_TARGET = 'richdocuments_openfile_target';
|
||||
|
||||
/** @var ?string */
|
||||
private $uid;
|
||||
/** @var IConfig */
|
||||
|
|
@ -140,12 +142,13 @@ class DocumentController extends Controller {
|
|||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @UseSession
|
||||
*
|
||||
* @param string $fileId
|
||||
* @param string|null $path
|
||||
* @return RedirectResponse|TemplateResponse
|
||||
*/
|
||||
public function index($fileId, $path = null) {
|
||||
public function index($fileId, ?string $path = null) {
|
||||
try {
|
||||
$folder = $this->rootFolder->getUserFolder($this->uid);
|
||||
|
||||
|
|
@ -188,6 +191,14 @@ class DocumentController extends Controller {
|
|||
'path' => $folder->getRelativePath($item->getPath()),
|
||||
];
|
||||
|
||||
$targetData = $this->session->get(self::SESSION_FILE_TARGET);
|
||||
if ($targetData) {
|
||||
$this->session->remove(self::SESSION_FILE_TARGET);
|
||||
if ($targetData['fileId'] === $item->getId()) {
|
||||
$params['target'] = $targetData['target'];
|
||||
}
|
||||
}
|
||||
|
||||
$encryptionManager = \OC::$server->getEncryptionManager();
|
||||
if ($encryptionManager->isEnabled()) {
|
||||
// Update the current file to be accessible with system public shared key
|
||||
|
|
@ -403,8 +414,9 @@ class DocumentController extends Controller {
|
|||
/**
|
||||
* @NoCSRFRequired
|
||||
* @NoAdminRequired
|
||||
* @UseSession
|
||||
*/
|
||||
public function editOnline(string $path = null, string $userId = null) {
|
||||
public function editOnline(string $path = null, ?string $userId = null, ?string $target = null) {
|
||||
if ($path === null) {
|
||||
return $this->renderErrorPage('No path provided');
|
||||
}
|
||||
|
|
@ -428,6 +440,46 @@ class DocumentController extends Controller {
|
|||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($userId);
|
||||
$file = $userFolder->get($path);
|
||||
if ($target !== null) {
|
||||
$this->session->set(self::SESSION_FILE_TARGET, [
|
||||
'fileId' => $file->getId(),
|
||||
'target' => $target,
|
||||
]);
|
||||
}
|
||||
$redirectUrl = $this->urlGenerator->getAbsoluteURL('/index.php/f/' . $file->getId());
|
||||
return new RedirectResponse($redirectUrl);
|
||||
} catch (NotFoundException $e) {
|
||||
} catch (NotPermittedException $e) {
|
||||
} catch (NoUserException $e) {
|
||||
}
|
||||
|
||||
return $this->renderErrorPage('File not found', Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoCSRFRequired
|
||||
* @NoAdminRequired
|
||||
* @UseSession
|
||||
*/
|
||||
public function editOnlineTarget(int $fileId, ?string $target = null) {
|
||||
if (!$this->uid) {
|
||||
return $this->renderErrorPage('File not found', Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($this->uid);
|
||||
$files = $userFolder->getById($fileId);
|
||||
$file = array_shift($files);
|
||||
if (!$file) {
|
||||
return $this->renderErrorPage('File not found', Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($target !== null) {
|
||||
$this->session->set(self::SESSION_FILE_TARGET, [
|
||||
'fileId' => $file->getId(),
|
||||
'target' => $target,
|
||||
]);
|
||||
}
|
||||
$redirectUrl = $this->urlGenerator->getAbsoluteURL('/index.php/f/' . $file->getId());
|
||||
return new RedirectResponse($redirectUrl);
|
||||
} catch (NotFoundException $e) {
|
||||
|
|
|
|||
75
lib/Controller/TargetController.php
Normal file
75
lib/Controller/TargetController.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Richdocuments\Controller;
|
||||
|
||||
use OC\User\NoUserException;
|
||||
use OCA\Richdocuments\Service\FileTargetService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataDisplayResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\NotPermittedException;
|
||||
use OCP\IRequest;
|
||||
|
||||
class TargetController extends \OCP\AppFramework\OCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private FileTargetService $fileTargetService,
|
||||
private IRootFolder $rootFolder,
|
||||
private ?string $userId,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function getTargets(string $path): DataResponse {
|
||||
try {
|
||||
$file = $this->getFile($path);
|
||||
return new DataResponse($this->fileTargetService->getFileTargets($file));
|
||||
} catch (NotFoundException $e) {
|
||||
}
|
||||
|
||||
return new DataResponse('File not found', Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function getPreview(string $path, string $target): Response {
|
||||
try {
|
||||
$file = $this->getFile($path);
|
||||
return new DataDisplayResponse(
|
||||
$this->fileTargetService->getTargetPreview($file, $target),
|
||||
Http::STATUS_OK,
|
||||
['Content-Type' => 'image/png']
|
||||
);
|
||||
} catch (NotFoundException $e) {
|
||||
return new DataResponse('File not found', Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
private function getFile(string $path): File {
|
||||
try {
|
||||
$file = $this->rootFolder->getUserFolder($this->userId)->get($path);
|
||||
|
||||
if (!$file instanceof File) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return $file;
|
||||
} catch (NotFoundException|NotPermittedException|NoUserException $e) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
lib/Listener/ReferenceListener.php
Normal file
45
lib/Listener/ReferenceListener.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
|
||||
namespace OCA\Richdocuments\Listener;
|
||||
|
||||
use OCP\Collaboration\Reference\RenderReferenceEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Util;
|
||||
|
||||
/** @template-implements IEventListener<Event|RenderReferenceEvent> */
|
||||
class ReferenceListener implements IEventListener {
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof RenderReferenceEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
Util::addScript('richdocuments', 'richdocuments-reference');
|
||||
}
|
||||
}
|
||||
148
lib/Reference/OfficeTargetReferenceProvider.php
Normal file
148
lib/Reference/OfficeTargetReferenceProvider.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Richdocuments\Reference;
|
||||
|
||||
use Exception;
|
||||
use OCA\Richdocuments\AppInfo\Application;
|
||||
use OCA\Richdocuments\Service\FileTargetService;
|
||||
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
|
||||
use OCP\Collaboration\Reference\IReference;
|
||||
use OCP\Collaboration\Reference\Reference;
|
||||
use OCP\Files\IMimeTypeDetector;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IURLGenerator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class OfficeTargetReferenceProvider extends ADiscoverableReferenceProvider {
|
||||
|
||||
public function __construct(
|
||||
private FileTargetService $fileTargetService,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IL10N $l10n,
|
||||
private IPreview $previewManager,
|
||||
private IMimeTypeDetector $mimeTypeDetector,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
private ?string $userId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function matchReference(string $referenceText): bool {
|
||||
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APPNAME);
|
||||
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APPNAME);
|
||||
|
||||
$noIndexMatch = preg_match('/^' . preg_quote($start, '/') . '\/editonline\/([0-9]+)\/(.*)$/', $referenceText) === 1;
|
||||
$indexMatch = preg_match('/^' . preg_quote($startIndex, '/') . '\/editonline\/([0-9]+)\/(.*)$/', $referenceText) === 1;
|
||||
|
||||
return $noIndexMatch || $indexMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function resolveReference(string $referenceText): ?IReference {
|
||||
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APPNAME);
|
||||
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APPNAME);
|
||||
|
||||
$matched = preg_match('/^' . preg_quote($start, '/') . '\/editonline\/([0-9]+)\/(.*)$/', $referenceText, $matches) === 1;
|
||||
if (!$matched) {
|
||||
$matched = preg_match('/^' . preg_quote($startIndex, '/') . '\/editonline\/([0-9]+)\/(.*)$/', $referenceText, $matches) === 1;
|
||||
}
|
||||
|
||||
if (!$matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fileId = (int)$matches[1];
|
||||
$target = urldecode($matches[2]);
|
||||
|
||||
try {
|
||||
$userFolder = $this->rootFolder->getUserFolder($this->userId);
|
||||
$files = $userFolder->getById($fileId);
|
||||
$file = array_shift($files);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->info('Failed to get file for office target reference: ' . $fileId, ['exception' => $e]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = null;
|
||||
if ($this->previewManager->isMimeSupported($file->getMimeType())) {
|
||||
$preview = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 1600, 'y' => 630, 'fileId' => $fileId]);
|
||||
} else {
|
||||
$fileTypeIconUrl = $this->mimeTypeDetector->mimeTypeIcon($file->getMimeType());
|
||||
$preview = $fileTypeIconUrl;
|
||||
}
|
||||
$targets = $this->fileTargetService->getFileTargets($file);
|
||||
foreach ($targets as $value) {
|
||||
$entries = $value['entries'];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry['id'] === $target) {
|
||||
$label = $entry['name'];
|
||||
$preview = $entry['preview'] ?? $preview;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$reference = new Reference($referenceText);
|
||||
$reference->setTitle($label ?? $file->getName());
|
||||
$reference->setDescription($label ? $file->getName(): null);
|
||||
$reference->setUrl($referenceText);
|
||||
|
||||
$reference->setImageUrl($preview);
|
||||
|
||||
/*
|
||||
FIXME: THis requires a change in the file provider to properly open the target link still
|
||||
$reference->setRichObject('file', [
|
||||
'id' => $file->getId(),
|
||||
'name' => $file->getName() . ' · ' . $label,
|
||||
'size' => $file->getSize(),
|
||||
'path' => $userFolder->getRelativePath($file->getPath()),
|
||||
'link' => $reference->getUrl(),
|
||||
'mimetype' => $file->getMimetype(),
|
||||
'mtime' => $file->getMTime(),
|
||||
'preview-available' => $this->previewManager->isAvailable($file)
|
||||
]);
|
||||
*/
|
||||
|
||||
return $reference;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getCachePrefix(string $referenceId): string {
|
||||
return $referenceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getCacheKey(string $referenceId): ?string {
|
||||
return $this->userId ?? '';
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return 'office-target';
|
||||
}
|
||||
|
||||
public function getTitle(): string {
|
||||
return $this->l10n->t('Link to office document section');
|
||||
}
|
||||
|
||||
public function getOrder(): int {
|
||||
return 90;
|
||||
}
|
||||
|
||||
public function getIconUrl(): string {
|
||||
return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APPNAME, 'app-dark.svg'));
|
||||
}
|
||||
}
|
||||
92
lib/Service/FileTargetService.php
Normal file
92
lib/Service/FileTargetService.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Richdocuments\Service;
|
||||
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\IL10N;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class FileTargetService {
|
||||
|
||||
public function __construct(
|
||||
private RemoteService $remoteService,
|
||||
private ICacheFactory $cacheFactory,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
private IL10N $l10n,
|
||||
private ?string $userId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFileTargets(File $file): array {
|
||||
$cache = $this->cacheFactory->createDistributed('richdocuments-filetarget');
|
||||
$cacheKey = $file->getId() . '_' . $file->getMTime();
|
||||
if ($cached = $cache->get($cacheKey)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$result = $this->remoteService->fetchTargets($file);
|
||||
|
||||
$categories = [];
|
||||
|
||||
$targets = $result['Targets'];
|
||||
|
||||
$filePath = $this->rootFolder->getUserFolder($this->userId)->getRelativePath($file->getPath());
|
||||
|
||||
// Limit what we support to display
|
||||
if (isset($targets['Headings'])) {
|
||||
$categories['headings'] = [
|
||||
'label' => $this->l10n->t('Headings'),
|
||||
'entries' => $this->mapTargets($filePath, $targets['Headings'])
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($targets['Sections'])) {
|
||||
$categories['sections'] = [
|
||||
'label' => $this->l10n->t('Sections'),
|
||||
'entries' => $this->mapTargets($filePath, $targets['Sections'])
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($targets['Images'])) {
|
||||
$categories['images'] = [
|
||||
'label' => $this->l10n->t('Images'),
|
||||
'entries' => $this->mapTargets($filePath, $targets['Images'])
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($targets['Sheets'])) {
|
||||
$categories['sheets'] = [
|
||||
'label' => $this->l10n->t('Sheets'),
|
||||
'entries' => $this->mapTargets($filePath, $targets['Sheets'])
|
||||
];
|
||||
}
|
||||
|
||||
$cache->set($cacheKey, $categories);
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
public function getTargetPreview($file, $target) {
|
||||
return $this->remoteService->fetchTargetThumbnail($file, $target);
|
||||
}
|
||||
|
||||
private function mapTargets(string $filePath, array $targets): array {
|
||||
$result = [];
|
||||
foreach ($targets as $name => $identifier) {
|
||||
$result[] = [
|
||||
'id' => $identifier,
|
||||
'name' => $name,
|
||||
// Disable previews for now as they may cause endless requests against Collabora
|
||||
// 'preview' => $this->urlGenerator->linkToOCSRouteAbsolute('richdocuments.Target.getPreview', [
|
||||
// 'path' => $filePath,
|
||||
// 'target' => $identifier,
|
||||
// ]),
|
||||
];
|
||||
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
88
lib/Service/RemoteService.php
Normal file
88
lib/Service/RemoteService.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Richdocuments\Service;
|
||||
|
||||
use Exception;
|
||||
use OCA\Richdocuments\AppConfig;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RemoteService {
|
||||
|
||||
public const REMOTE_TIMEOUT_DEFAULT = 25;
|
||||
|
||||
public function __construct(
|
||||
private AppConfig $appConfig,
|
||||
private IClientService $clientService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function fetchTargets($file): array {
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->put(
|
||||
$this->appConfig->getCollaboraUrlInternal(). '/cool/extract-link-targets',
|
||||
$this->getRequestOptionsForFile($file)
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->warning('Failed to fetch extract-link-targets', ['exception' => $e]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = trim($response->getBody());
|
||||
$json = str_replace(['", }', "\r\n", "\t"], ['" }', '\r\n', '\t'], $json);
|
||||
try {
|
||||
$result = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->warning('Failed to parse extract-link-targets response', ['exception' => $e]);
|
||||
return [];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function fetchTargetThumbnail(File $file, string $target): ?string {
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->put($this->appConfig->getCollaboraUrlInternal(). '/cool/get-thumbnail', $this->getRequestOptionsForFile($file, $target));
|
||||
return (string)$response->getBody();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->info('Failed to fetch target thumbnail', ['exception' => $e]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getRequestOptionsForFile(File $file, ?string $target = null): array {
|
||||
$useTempFile = $file->isEncrypted() || !$file->getStorage()->isLocal();
|
||||
if ($useTempFile) {
|
||||
$localFile = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (!is_string($localFile)) {
|
||||
throw new NotFoundException('Could not get local file');
|
||||
}
|
||||
$stream = fopen($localFile, 'rb');
|
||||
} else {
|
||||
$stream = $file->fopen('rb');
|
||||
}
|
||||
|
||||
$options = [
|
||||
'timeout' => self::REMOTE_TIMEOUT_DEFAULT,
|
||||
'multipart' => [
|
||||
['name' => $file->getName(), 'contents' => $stream],
|
||||
['name' => 'target', 'contents' => $target]
|
||||
]
|
||||
];
|
||||
|
||||
if ($this->appConfig->getDisableCertificateValidation()) {
|
||||
$options['verify'] = false;
|
||||
}
|
||||
|
||||
$options['headers'] = [
|
||||
'User-Agent' => 'Nextcloud Server / richdocuments',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
|
@ -247,7 +247,7 @@ const documentsMain = {
|
|||
$(document.body).addClass('claro')
|
||||
$(document.body).prepend(documentsMain.UI.container)
|
||||
|
||||
const urlsrc = getWopiUrl({ fileId, title, readOnly: false, closeButton: !documentsMain.hideCloseButton, revisionHistory: !documentsMain.isPublic })
|
||||
const urlsrc = getWopiUrl({ fileId, title, readOnly: false, closeButton: !documentsMain.hideCloseButton, revisionHistory: !documentsMain.isPublic, target: Config.get('target') })
|
||||
|
||||
// access_token - must be passed via a form post
|
||||
const accessToken = encodeURIComponent(documentsMain.token)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const getSearchParam = (name) => {
|
|||
return decodeURI(results[1]) || ''
|
||||
}
|
||||
|
||||
const getWopiUrl = ({ fileId, title, readOnly, closeButton, revisionHistory }) => {
|
||||
const getWopiUrl = ({ fileId, title, readOnly, closeButton, revisionHistory, target = undefined }) => {
|
||||
// Only set the revision history parameter if the versions app is enabled
|
||||
revisionHistory = revisionHistory && window?.oc_appswebroots?.files_versions
|
||||
|
||||
|
|
@ -53,6 +53,7 @@ const getWopiUrl = ({ fileId, title, readOnly, closeButton, revisionHistory }) =
|
|||
+ (closeButton ? '&closebutton=1' : '')
|
||||
+ (revisionHistory ? '&revisionhistory=1' : '')
|
||||
+ (readOnly ? '&permission=readonly' : '')
|
||||
+ (target ? '&target=' + encodeURIComponent(target) : '')
|
||||
}
|
||||
|
||||
const getDocumentUrlFromTemplate = (templateId, fileName, fileDir, fillWithTemplate) => {
|
||||
|
|
|
|||
45
src/reference.js
Normal file
45
src/reference.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Vue from 'vue'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import { registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Components/NcRichText.js'
|
||||
|
||||
import DocumentTargetPicker from './view/DocumentTargetPicker.vue'
|
||||
|
||||
Vue.mixin({
|
||||
methods: {
|
||||
t,
|
||||
},
|
||||
})
|
||||
registerCustomPickerElement('office-target', (el, { providerId, accessible }) => {
|
||||
const Element = Vue.extend(DocumentTargetPicker)
|
||||
const vueElement = new Element({
|
||||
propsData: {
|
||||
providerId,
|
||||
accessible,
|
||||
},
|
||||
}).$mount(el)
|
||||
return new NcCustomPickerRenderResult(vueElement.$el, vueElement)
|
||||
}, (el, renderResult) => {
|
||||
renderResult.object.$destroy()
|
||||
})
|
||||
204
src/view/DocumentTargetPicker.vue
Normal file
204
src/view/DocumentTargetPicker.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<!--
|
||||
- @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
|
||||
-
|
||||
- @author Julius Härtl <jus@bitgrid.net>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="filePath === null" class="office-target-picker">
|
||||
<div ref="picker" class="reference-file-picker" />
|
||||
</div>
|
||||
<div v-else class="office-target-picker">
|
||||
<h2>{{ t('richdocuments', 'Link to office document section') }}</h2>
|
||||
<NcLoadingIcon v-if="!sections || !fileId" :size="44" />
|
||||
<div v-else>
|
||||
<NcEmptyContent v-if="sections.length === 0" :title="t('richdocuments', 'Could not find any section in the document')">
|
||||
<template #icon>
|
||||
<TableOfContentsIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<template v-else>
|
||||
<div v-for="section in sections" :key="section.label">
|
||||
<h3>{{ section.label }}</h3>
|
||||
<ul>
|
||||
<NcListItem v-for="entry in section.entries"
|
||||
:key="entry.id"
|
||||
:title="entry.name"
|
||||
:class="{ 'list-item__wrapper--active': entry.id === target }"
|
||||
@click="setTarget(entry)">
|
||||
<template v-if="entry.preview" #icon>
|
||||
<img :src="entry.preview">
|
||||
</template>
|
||||
</NcListItem>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="sections.length !== 0" class="office-target-picker__buttons">
|
||||
<NcButton type="primary" :disabled="!target" @click="submit()">
|
||||
{{ t('richdocuments', 'Link to office document section') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FilePickerType } from '@nextcloud/dialogs'
|
||||
import { generateUrl, generateOcsUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { NcButton, NcEmptyContent, NcListItem, NcLoadingIcon } from '@nextcloud/vue'
|
||||
import TableOfContentsIcon from 'vue-material-design-icons/TableOfContents.vue'
|
||||
|
||||
export default {
|
||||
name: 'DocumentTargetPicker',
|
||||
components: {
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
NcListItem,
|
||||
NcLoadingIcon,
|
||||
TableOfContentsIcon,
|
||||
},
|
||||
props: {
|
||||
providerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
accessible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fileId: null,
|
||||
filePath: null,
|
||||
target: null,
|
||||
sections: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.openFilePicker()
|
||||
window.addEventListener('click', this.onWindowClick)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('click', this.onWindowClick)
|
||||
},
|
||||
methods: {
|
||||
onWindowClick(e) {
|
||||
if (e.target.tagName === 'A' && e.target.classList.contains('oc-dialog-close')) {
|
||||
this.$emit('cancel')
|
||||
}
|
||||
},
|
||||
async openFilePicker() {
|
||||
const self = this
|
||||
OC.dialogs.filepicker(
|
||||
t('files', 'Select file or folder to link to'),
|
||||
function(file) {
|
||||
const client = OC.Files.getClient()
|
||||
client.getFileInfo(file).then((_status, fileInfo) => {
|
||||
self.fileId = fileInfo.id
|
||||
})
|
||||
self.filePath = file
|
||||
self.fetchReferences()
|
||||
},
|
||||
false, // multiselect
|
||||
OC.getCapabilities().richdocuments.mimetypes, // mime filter
|
||||
false, // modal
|
||||
FilePickerType.Choose, // type
|
||||
'',
|
||||
{
|
||||
target: this.$refs.picker,
|
||||
},
|
||||
)
|
||||
},
|
||||
setTarget(entry) {
|
||||
this.target = entry.id
|
||||
},
|
||||
submit() {
|
||||
const fileLink = window.location.protocol + '//' + window.location.host
|
||||
+ generateUrl('/apps/richdocuments/editonline/{fileId}/{target}', { fileId: this.fileId, target: this.target })
|
||||
this.$emit('submit', fileLink)
|
||||
},
|
||||
async fetchReferences() {
|
||||
const response = await axios.get(generateOcsUrl('/apps/richdocuments/api/v1/targets'), {
|
||||
params: {
|
||||
path: this.filePath,
|
||||
},
|
||||
})
|
||||
this.sections = response.data.ocs.data
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reference-file-picker {
|
||||
flex-grow: 1;
|
||||
|
||||
&:deep(.oc-dialog) {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
flex-grow: 1 !important;
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
max-width: initial;
|
||||
|
||||
.oc-dialog-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.oc-dialog-buttonrow.onebutton.aside {
|
||||
position: absolute;
|
||||
padding: 12px 32px;
|
||||
}
|
||||
|
||||
.oc-dialog-content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.office-target-picker {
|
||||
margin: calc(var(--default-grid-baseline) * 4);
|
||||
flex-grow: 1;
|
||||
height: 80vh;
|
||||
|
||||
&__buttons {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
color: var(--color-primary-element);
|
||||
font-size: var(--default-font-size);
|
||||
line-height: 44px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: .7;
|
||||
box-shadow: none !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -334,7 +334,7 @@ export default {
|
|||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
background-color: var(--color-main-background);
|
||||
&.debug {
|
||||
opacity: .5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ webpackConfig.entry = {
|
|||
document: path.join(__dirname, 'src', 'document.js'),
|
||||
admin: path.join(__dirname, 'src', 'admin.js'),
|
||||
personal: path.join(__dirname, 'src', 'personal.js'),
|
||||
reference: path.join(__dirname, 'src', 'reference.js'),
|
||||
}
|
||||
|
||||
webpackRules.RULE_JS.test = /\.m?js$/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue