Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace OCA\Office\AppInfo;

use OCA\Office\Listener\AppMenuActionListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;

final class Application extends App implements IBootstrap {
public const APP_ID = 'office';
Expand All @@ -19,6 +21,7 @@ public function __construct() {

#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerEventListener(LoadAdditionalEntriesEvent::class, AppMenuActionListener::class);
}

#[\Override]
Expand Down
10 changes: 10 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
namespace OCA\Office\Controller;

use OCA\Office\AppInfo\Application;
use OCA\Office\Service\CreatorCategoryService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;

/**
Expand All @@ -20,6 +22,8 @@ final class PageController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private CreatorCategoryService $categoryService,
private IInitialState $initialState,
) {
parent::__construct($appName, $request);
}
Expand All @@ -28,7 +32,13 @@ public function __construct(
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/')]
#[FrontpageRoute(verb: 'GET', url: '/{path}', requirements: ['path' => '.*'], defaults: ['path' => ''], postfix: 'path')]
public function index(): TemplateResponse {
// The category a creator belongs to, its id and its label are decided
// server-side; the frontend joins these onto the creators it gets from
// the templates API.
$this->initialState->provideInitialState('creator-categories', $this->categoryService->listCategories());

// editor-url is not provided here — OfficeOverview.vue calls
// loadState('office', 'editor-url', null) and falls back to /f/{fileid}
// when the state is absent. A WOPI backend branch injects a real URL.
Expand Down
85 changes: 85 additions & 0 deletions lib/Listener/AppMenuActionListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace OCA\Office\Listener;

use OCA\Office\AppInfo\Application;
use OCA\Office\Service\CreatorCategoryService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\INavigationManager;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;

/**
* Adds one app menu action per registered template creator, linking to that
* creator's category in the office app.
*
* @template-implements IEventListener<LoadAdditionalEntriesEvent>
*/
final class AppMenuActionListener implements IEventListener {
/** @psalm-suppress PossiblyUnusedMethod Constructed by the DI container */
public function __construct(
private CreatorCategoryService $categoryService,
private INavigationManager $navigationManager,
private IURLGenerator $urlGenerator,
private IUserSession $userSession,
) {
}

#[\Override]
public function handle(Event $event): void {
if (!$event instanceof LoadAdditionalEntriesEvent) {
return;
}

// Creators are registered per user, so there is nothing to offer a guest.
if (!$this->userSession->isLoggedIn()) {
return;
}

$seen = [];
foreach ($this->categoryService->listCreatorCategories() as $category) {
$id = $category['id'];
// Two suites can register a creator for the same category; the first
// one wins, matching which one the category URL resolves to.
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;

$entry = [
'id' => Application::APP_ID . '-' . $id,
'app' => Application::APP_ID,
'type' => INavigationManager::TYPE_ACTION,
'order' => $category['order'],
'href' => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.indexpath', ['path' => $id]),
'name' => $category['label'],
'icon' => $this->icon($category['iconSvgInline']),
];

// The indicator is only rendered when a color is set, and a category
// outside the static map has none to give.
if ($category['color'] !== null) {
$entry['color'] = $category['color'];
}

$this->navigationManager->add($entry);
}
}

/**
* Creators ship their icon as inline SVG, which the app menu cannot use: it
* paints the icon as a CSS background, so it needs a URL. A data URI keeps
* the creator's own icon without a route to serve it, and cannot execute the
* script an SVG may carry.
*/
private function icon(?string $svg): string {
if ($svg === null || $svg === '') {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app.svg');
}
return 'data:image/svg+xml;base64,' . base64_encode($svg);
}
}
226 changes: 226 additions & 0 deletions lib/Service/CreatorCategoryService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare(strict_types=1);

namespace OCA\Office\Service;

use OCA\Office\AppInfo\Application;
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\TemplateFileCreator;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IL10N;
use OCP\IUserSession;

/**
* Maps the registered template creators onto the categories the office app
* presents them as.
*
* A category id is the creator's identity in URLs (/apps/office/<id>) and in the
* app menu, so it stays stable when an admin switches doc_format, which changes
* a creator's extension and mimetypes.
*
* @psalm-type OfficeCategory = array{app: string, extension: string, id: string, label: string, mimetypes: list<string>}
* @psalm-type OfficeCreatorCategory = array{app: string, extension: string, id: string, label: string, mimetypes: list<string>, color: ?string, order: int, iconSvgInline: ?string}
*/
final class CreatorCategoryService {
private const array MIME_CATEGORIES = [
'application/vnd.oasis.opendocument.text' => 'documents',
'application/vnd.oasis.opendocument.text-template' => 'documents',
'application/msword' => 'documents',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'documents',
'application/vnd.oasis.opendocument.spreadsheet' => 'spreadsheets',
'application/vnd.oasis.opendocument.spreadsheet-template' => 'spreadsheets',
'application/vnd.ms-excel' => 'spreadsheets',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'spreadsheets',
'application/vnd.oasis.opendocument.presentation' => 'presentations',
'application/vnd.oasis.opendocument.presentation-template' => 'presentations',
'application/vnd.ms-powerpoint' => 'presentations',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'presentations',
'application/vnd.oasis.opendocument.graphics' => 'diagrams',
'application/vnd.oasis.opendocument.graphics-template' => 'diagrams',
];

/**
* Registering the creators runs every app that offers one, on every page
* load the app menu is built for. What comes out of it only changes when an
* app is installed, enabled or reconfigured, so it is worth a cache — bound
* by a TTL rather than invalidated, as none of those are observable here.
*/
private const int CACHE_TTL = 3600;

private ICache $cache;

/**
* A page of this app builds both the app menu and its own initial state, so
* the cache would be asked twice in one request.
*
* @var ?list<OfficeCreatorCategory>
*/
private ?array $creatorCategories = null;

/** @psalm-suppress PossiblyUnusedMethod Constructed by the DI container */
public function __construct(
private IL10N $l10n,
private ITemplateManager $templateManager,
private IUserSession $userSession,
ICacheFactory $cacheFactory,
) {
$this->cache = $cacheFactory->createDistributed(Application::APP_ID . '-creator-categories');
}

/**
* Every registered creator with the category it belongs to. The frontend
* joins these onto the creators it gets from the templates API by app and
* extension.
*
* @return list<OfficeCategory>
*/
public function listCategories(): array {
return array_map(
static fn (array $category): array => [
'app' => $category['app'],
'extension' => $category['extension'],
'id' => $category['id'],
'label' => $category['label'],
'mimetypes' => $category['mimetypes'],
],
$this->listCreatorCategories(),
);
}

/**
* Every registered creator with everything the app menu presents it by.
* Nothing in here is request-scoped — no URLs, no theming — because it is
* served from the cache across requests.
*
* @return list<OfficeCreatorCategory>
*/
public function listCreatorCategories(): array {
if ($this->creatorCategories !== null) {
return $this->creatorCategories;
}

$key = $this->cacheKey();
if ($key !== null) {
/** @var ?list<OfficeCreatorCategory> $cached Written by this class only */
$cached = $this->cache->get($key);
if ($cached !== null) {
return $this->creatorCategories = $cached;
}
}

$categories = $this->describeCreators();
if ($key !== null) {
$this->cache->set($key, $categories, self::CACHE_TTL);
}
return $this->creatorCategories = $categories;
}

/**
* Creators are registered per user, and their labels are translated into the
* user's language: both belong in the key. A request without a user has
* nothing stable to key on, so its result is not cached.
*/
private function cacheKey(): ?string {
$uid = $this->userSession->getUser()?->getUID();
return $uid === null ? null : $uid . '-' . $this->l10n->getLanguageCode();
}

/**
* @return list<OfficeCreatorCategory>
*/
private function describeCreators(): array {
$categories = [];
foreach ($this->listCreators() as $creator) {
$description = $this->describe($creator);
$categories[] = [
'app' => $creator->getAppId(),
'extension' => $description['extension'],
'id' => $this->categoryId($creator),
'label' => $this->categoryLabel($creator),
'mimetypes' => $this->categoryMimetypes($creator),
'color' => $this->categoryColor($creator),
'order' => $creator->getOrder(),
'iconSvgInline' => $description['iconSvgInline'],
];
}
return $categories;
}

/**
* @return list<TemplateFileCreator> Registered creators, ordered by the order they declared
*/
private function listCreators(): array {
/** @var list<TemplateFileCreator> $creators ITemplateManager::listCreators() is untyped */
$creators = $this->templateManager->listCreators();
return $creators;
}

/**
* Creators the static map does not cover fall back to app and extension:
* locale-independent and stable, but tied to the create format.
*/
public function categoryId(TemplateFileCreator $creator): string {
$category = $this->category($creator);
if ($category !== null) {
return $category;
}
return $creator->getAppId() . '-' . ltrim($this->describe($creator)['extension'], '.');
}

public function categoryLabel(TemplateFileCreator $creator): string {
return match ($this->category($creator)) {
'documents' => $this->l10n->t('Documents'),
'spreadsheets' => $this->l10n->t('Spreadsheets'),
'presentations' => $this->l10n->t('Presentations'),
'diagrams' => $this->l10n->t('Diagrams'),
default => $this->describe($creator)['label'],
};
}

public function categoryColor(TemplateFileCreator $creator): ?string {
return match ($this->category($creator)) {
'documents' => '#49abea',
'spreadsheets' => '#9abd4e',
'presentations' => '#f18500',
'diagrams' => '#d93f0b',
default => null,
};
}

/**
* Both the ODF and the OOXML mimetypes of the category, so a category lists
* every file it can open regardless of the configured create format, plus
* whatever the creator advertises beyond the static map.
*
* @return list<string>
*/
public function categoryMimetypes(TemplateFileCreator $creator): array {
$category = $this->category($creator);
$mimetypes = $category === null
? []
: array_keys(self::MIME_CATEGORIES, $category, true);
return array_values(array_unique([...$mimetypes, ...$this->describe($creator)['mimetypes']]));
}

private function category(TemplateFileCreator $creator): ?string {
foreach ($this->describe($creator)['mimetypes'] as $mime) {
if (isset(self::MIME_CATEGORIES[$mime])) {
return self::MIME_CATEGORIES[$mime];
}
}
return null;
}

/**
* TemplateFileCreator exposes neither its action name nor its extension
* through a getter, and getMimetypes() is untyped; the serialized form
* carries all three with types.
*
* @return array{app: string, label: string, extension: string, iconClass: ?string, iconSvgInline: ?string, mimetypes: list<string>, ratio: ?float, actionLabel: string}
*/
private function describe(TemplateFileCreator $creator): array {
return $creator->jsonSerialize();
}
}
Loading
Loading