File plugins

File plugins are the parts of FileRun that open a file: the viewers, the editors, the converters and the actions listed in the Open with.. menu. The image editor, the text editor, the PDF tools, the ONLYOFFICE integration and the Autodesk viewer are all file plugins.

The first part of this page is for administrators: how plugins show up and how they are configured. The second part, Creating your own plugin, is for developers.

Using file plugins

Opening a file with a plugin

Right-click a file and open Open with... The list contains every plugin that accepts the file's type and that your permissions allow: a plugin which needs the download permission is not offered on a file you are not allowed to download.

Double-clicking a file, or choosing Preview, opens it with the default preview plugin of its type. The Edit menu item opens it with the default edit plugin, and is shown only for file types that have one.

Creating new files

A plugin that can create files adds an entry to the New button (for example Text document or Spreadsheet). FileRun asks for a file name, creates the file in the current folder and opens it with the plugin.

Choosing the default plugin for a file type

Open the control panel, Files โ†’ Plugins โ†’ Defaults. Each entry pairs a file type (such as "Office documents") or one file extension with the plugin used for preview and the plugin used for edit.

Enabling, disabling and configuring a plugin

Open the control panel, Files โ†’ Plugins, select the plugin and click Edit. Every plugin has these options:

Below them come the plugin's own settings, when it has any โ€” the server address of ONLYOFFICE, the API key of a conversion service, and so on. A plugin that needs such a setting stays hidden until it is configured.

The built-in viewers and editors are listed on the file preview options page.

Plugins and shared links

A shared link opens the same file manager, signed in as the link's visitor. Plugins work there in the same way, limited by the visitor's permissions: with download allowed but editing not, the viewers are available and the editors open read-only.

Creating your own plugin

Everything from here on is for developers. It describes the plugin interface of FileRun in enough detail to write a plugin, and ends with two complete, working examples: a viewer that reads a file and an editor that writes one.

Before you start

How FileRun finds a plugin

There is no registry, manifest or database entry. FileRun looks, in every app folder, for the folder !classes/Files/Handlers/Handlers/ and treats each folder inside it as a plugin:

apps/{App}/!classes/Files/Handlers/Handlers/{Id}/{Id}.php

must hold the class FR\{App}\Files\Handlers\Handlers\{Id}\{Id}, extending FR\Drive\Files\Handlers\AbstractHandler. Creating that file is the whole registration. Folders whose name starts with _ are ignored. (In FileRun's code a plugin is called a handler; the two words mean the same thing.)

A new app needs exactly one more file, which connects the plugin's pages to FileRun's routing:

 1<?php
 2declare(strict_types = 1);
 3
 4require FR::$path->apps.'/Drive/ui/handlers/handlers/!includes/!init.php';

saved as apps/{App}/ui/handlers/handlers/!includes/!init.php. That is the exact content of the file in apps/Autodesk.

Folder layout

 1apps/{App}/
 2  !classes/Files/Handlers/Handlers/{Id}/
 3    {Id}.php                   the plugin class (required)
 4    HandlerSettings.php        how it appears in the interface (required
 5                               in practice: without it the plugin has no
 6                               menu conditions)
 7    Save.php, CreateBlank.php  optional helper classes, see "Writing
 8                               files"
 9  ui/handlers/handlers/
10    !includes/!init.php        the one-line file shown above
11    {Id}/
12      index.php                the page opened in the popup
13      !actions/save.php        endpoints called by the page (optional)
14      !actions/create_blank.php
15  !public/
16    js/min/{name}.js.php       the page's JavaScript, see "JavaScript
17                               and CSS"
18    css/min/{name}.css.php     the page's stylesheet

Folder names starting with ! have a fixed meaning in FileRun: !classes and !includes cannot be reached by URL, !actions holds endpoints protected against cross-site request forgery (they need the CSRF token), and !public needs no authentication. Everything else under ui/ requires a signed-in user.

The handler class

The class is almost entirely declaration. A complete viewer can be this short:

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\TextViewer;
 5
 6use FR\Drive\Files\Handlers\AbstractHandler;
 7
 8class TextViewer extends AbstractHandler {
 9
10	public bool $online = false;
11	public string $title = 'Text Viewer';
12	public ?string $iconCls = 'fa-file-lines';
13	public null|array|bool $supportedExtensions = ['txt', 'log', 'ini'];
14
15}

Properties you can set:

Property Type Meaning
$title string The name shown in the menus and the control panel. Also names the plugin's translation section, File Plugins/{title}.
$iconCls ?string A Font Awesome class (fa-pen, fa-file-lines, โ€ฆ) for the menu icon.
$icon ?string Alternative to $iconCls: the path of an image, relative to FileRun's root. Set one of the two, not both.
$supportedExtensions null|array|bool The file extensions the plugin accepts, lower case: ['jpg', 'png']. null accepts every file, false keeps the plugin out of Open with.. entirely.
$supportedTypes null|array|bool Same, by FileRun's file type keys instead of extensions โ€” among them img, txt, code, csv, word, sheet, show, audio, video, arch, cad, 3d, url.
$online bool true (the default) when the plugin needs an internet connection. Only affects the order of the list: offline plugins are listed first.
$immutable bool true removes the "Hide plugin" option in the control panel.
$abstract bool true hides the class from every list; for a base class that other plugins extend.

Methods the class inherits, to use in your pages:

Method What it does
getURL(?string $path = null, array $params = []) Builds the URL of one of the plugin's routes: getURL('!actions/save', ['paths' => [...]]).
getOpenInBrowserURL(PathInfo $pathInfo, int $version = 0) A URL that streams the raw file to the browser, with the permission check and the activity log entry done. Use it for images, PDFs, media.
getFirstItem(): PathInfo The first file the plugin was opened with (throws when there is none).
getSubClass(string $name): ?object Creates FR\{App}\Files\Handlers\Handlers\{Id}\{$name} with the plugin passed to its constructor; null if the class does not exist.
getSetting(string $key): mixed Reads one of the plugin's control panel settings (see below).
t(string $text, array $vars = []): string Translates a string in the plugin's own section. %1, %2โ€ฆ in the text are replaced by $vars.
page(array $page): void Renders the popup page; see "The plugin's pages and actions".
isEnabled(): bool Override it to return false while the plugin is not configured (no server address yet, for example). A disabled plugin is skipped everywhere.

The plugin may need to run only on files with the download permission; that is the default. public array $requires on the class is not what decides the menu conditions โ€” the requires entry of the interface configuration below is.

How the plugin appears in the interface

HandlerSettings.php, in the same folder, extends FR\Drive\Files\Handlers\AbstractHandlerSettings and declares the interface configuration, either as a property or as a method:

 1class HandlerSettings extends AbstractHandlerSettings {
 2
 3	public array $ui = [
 4		'requires' => ['download'],
 5		'width' => 700,
 6		'height' => 500
 7	];
 8
 9}

Use the method form, public function ui(): array, when a value needs computing or translating; $this->handler is the plugin object there. The array is sent to the browser as it is, so only plain values work. Keys FileRun reads:

Key Effect
requires The conditions under which the Open with.. item is enabled; a list of names from the table below. single (one selected item) is added for you unless the list contains multiple.
createNew Adds an entry to the New button; see below.
replaceDoubleClickAction true makes a double-click on a supported file run this plugin instead of the preview. Works only together with $supportedExtensions.
newTab true opens the plugin in a new browser tab always. The administrator's "Open in a new browser tab" option is then shown checked and locked.
notModal true lets the user keep working in the file manager while the popup is open.
ajax true for a plugin without a page: the selected paths are posted to {plugin URL}/!actions/ajax and the file list is refreshed from the answer (refresh and highlight keys).
anything else Passed to the popup window: width, height, maximized: true, resizable: falseโ€ฆ

Condition names for requires:

Checks Condition names
Permissions of the selected item download, upload, edit, weblink, share, comment, renameMoveDelete, readMetadata, editMetadata, notReadOnly
The user's own permissions, ignoring the selection downloadFiles, userUpload, userAlter, admin
The selection single, multiple, oneOrNone, noSelection, withSelection, isFile, isFolder, onlyFiles, onlyFolders, isCollection, isNotVirtual
The place inGrid, inTree, searchMode, notSearchMode, notMobile, notTheHomeFolder
The type images, pdfs

A condition can also be ['skip_sections' => ['trash']] to hide the item in the named sections.

The createNew entry:

 1'createNew' => [
 2	'title' => $this->handler->t('Note'),
 3	'defaultFileName' => $this->handler->t('New note.txt'),
 4	'iconCls' => 'fa-pen',
 5	'requires' => ['downloadFiles']
 6]

FileRun asks the user for a name (pre-filled with defaultFileName), posts it as fileName to {plugin URL}/!actions/create_blank together with the current folder in paths[], and, when the answer reports success, opens the new file with the plugin. Instead of a single defaultFileName you can give options, a list of ['fileName' => โ€ฆ, 'title' => โ€ฆ, 'iconCls' => โ€ฆ] entries, which turns the entry into a submenu (the text editor offers .txt and .md that way).

Control panel settings for your plugin

Give HandlerSettings a getFields() method and FileRun renders a settings form for the plugin in the control panel, stores the values and hands them back through $handler->getSetting():

 1public function getFields(): array {
 2	return [
 3		'url' => [
 4			'fieldLabel' => 'Server address',
 5			'helpText' => 'The URL of the conversion server, including https://'
 6		],
 7		'api_key' => [
 8			'fieldLabel' => 'API key'
 9		],
10		'keep_copy' => [
11			'xtype' => 'checkbox',
12			'boxLabel' => 'Keep a copy of the original file'
13		]
14	];
15}

Each key becomes the setting custom_action_{App}.{Id}_{key}; $this->getSetting('url') in the plugin class reads it back. A field without xtype is a text field. Labels and help texts are translated in the plugin's section. Two details worth knowing: a key containing path is normalized as a file system path when saved, and the values of keys containing key, secret or client are masked on FileRun's public demo.

Optional additions on HandlerSettings:

To keep the plugin out of the menus until it is configured, override isEnabled() on the plugin class:

 1function isEnabled(): bool {
 2	return (bool) $this->getSetting('url');
 3}

The plugin's pages and actions

The files under apps/{App}/ui/handlers/handlers/{Id}/ are pages and actions of your plugin, reached at

{FileRun URL}/app/{App}/ui/handlers/handlers/{Id}/{file name without .php}

Do not build that URL by hand; $handler->getURL() does it. Before any of these files runs, FileRun has prepared three variables:

The plugin's translation section is loaded as well, so Ext.T('โ€ฆ') works in the page's JavaScript when the page includes FileRun's interface library ('Core/ext' in scripts).

index.php is what the popup loads. It ends with $handler->page([...]), which renders FileRun's page template. Keys:

Key Meaning
scripts JavaScript to load: '{App}/{name}' for a file of yours (see "JavaScript and CSS"), 'Core/ext' for FileRun's interface library, or an absolute URL. An entry ['src' => $url, 'type' => 'module'] loads an ES module.
styles Stylesheets, the same way. 'Core/basic' gives the page FileRun's fonts, colours and dark mode.
jsGlobals Values for the page's JavaScript: ['FR.vars' => [...]] is printed as FR.vars = {...} after the script tags, so read it from a DOMContentLoaded listener, not at the top level of your script.
contents HTML for the body.
file Alternative to contents: a PHP file to include for the body.
loadMsg true (the default) shows a "Loadingโ€ฆ" message and loads the scripts at the end of the body; false loads them in the head.
title The page title; defaults to the file name.
links Raw HTML added to the head, before the scripts โ€” an import map, for example.

The page always receives FR.url (the FileRun URLs), FR.csrf (the token the actions need), FR.paths (the paths the plugin was opened with) and, inside a popup, FR.windowId.

!actions/*.php are the endpoints the page calls. They are small: read the request, do the work through the action classes described in the next two sections, and describe the outcome through FR\Core\Response\Response:

 1Response::addMessage($message);
 2Response::addUpdate($pathInfo->relativePath, [
 3	'refresh' => Updates::getUpdatedDetails($pathInfo)
 4]);
 5Response::markSuccessful();

addUpdate() is what refreshes the file's row in the file list behind the popup (size, date, thumbnail). Anything thrown and not caught turns into a failure answer with success: false; throw FR\Core\PublicException when the message is meant for the user, any other exception for errors that should only reach the log.

The page's JavaScript calls an action with fetch(), sending the CSRF token and asking for JSON:

 1const form = new FormData();
 2form.append('csrf', FR.csrf);
 3form.append('file', new Blob([text]), FR.vars.fileName);
 4const response = await fetch(FR.vars.saveURL, {
 5	method: 'POST',
 6	headers: {'Accept': 'application/json'},
 7	body: form
 8});
 9const rs = await response.json();

The answer is {"success": true, "msg": ["..."], "updates": [...]}, or {"success": false, "msg": ["..."]}. The token can also be sent as the X-CSRF-TOKEN header. A request without the Accept header gets an HTML page instead of JSON.

Reading files

Never open a file path directly. The read action checks the user's permission, resolves older versions and writes the activity log entry. The short way, for a file that fits in memory:

 1use FR\Drive\Files\Actions\Read\FileReadWithHandler;
 2use FR\Drive\Files\Actions\Read\ReadFileContents;
 3
 4$pathInfo = $handler->getFirstItem();
 5
 6$read = FileReadWithHandler::byPathInfo($pathInfo, $handler, $version);
 7$text = ReadFileContents::getContents($read);

ReadFileContents::getContents() returns the whole file as a string and logs the read for you. Two optional arguments, $start and $length in bytes, read only a part of the file: getContents($read, 0, 1024) gives the first kilobyte.

The long way, when you want to stream the file โ€” it is large, you pass it to a library that reads from a stream, or you send it to the browser piece by piece:

 1use FR\Drive\Files\Actions\Read\FileReadWithHandler;
 2use FR\Drive\Files\Actions\Read\ReadFilePointer;
 3
 4$pathInfo = $handler->getFirstItem();
 5
 6$read = FileReadWithHandler::byPathInfo($pathInfo, $handler, $version);
 7$filePointer = ReadFilePointer::getPointer($read);
 8while (!feof($filePointer)) {
 9	echo fread($filePointer, 8192);
10}
11fclose($filePointer);
12$read->onSuccess();

ReadFilePointer::getPointer() returns a standard PHP stream opened for reading; $read->fullPath holds the path when you need to hand it to a library that wants a file name instead. Here the logging is up to you: call $read->onSuccess() once the read is done, and $read->onFailure($exception) if it goes wrong.

For a file the browser should fetch itself โ€” an image, a PDF, a video โ€” do not read it in PHP at all: pass $handler->getOpenInBrowserURL($pathInfo, $version) to the page and let the browser load that URL.

FileReadWithHandler throws for a user who is not allowed to download the file, so nothing after it runs for that user; getFirstItem() throws when the page was opened without a file.

Writing files

Writing goes through action classes too; they carry the permission check, the quota, the file versioning, the activity log and the notifications:

Update an existing file:

 1use FR\Drive\Files\Actions\Alter\Update\UpdateFileWithHandler;
 2
 3$message = UpdateFileWithHandler::byPathInfo($pathInfo, $handler)->run($dataSource);

Create a new file ($folder is a FR\Drive\Files\Folder, made with Folder::byPathInfo($pathInfo)):

 1use FR\Drive\Files\Actions\Write\CreateFileWithHandler;
 2use FR\Drive\Files\Folder;
 3
 4$folder = Folder::byPathInfo($pathInfo);
 5$message = (new CreateFileWithHandler($folder, $fileName, $handler))->run($dataSource);

Both return the message to show.

Data sources

$dataSource says where the bytes come from. It is an object from FR\Drive\Files\DataSource\; the action asks it for the expected size first, to check the maximum upload size and the user's quota before anything is written, and then tells it where to write. Pick the one that matches what you have:

A string โ€” the content is already in memory, as when the page posts the edited text of a document:

 1use FR\Drive\Files\DataSource\StringDataSource;
 2
 3$text = (string) ($_POST['content'] ?? '');
 4$dataSource = new StringDataSource($text);

The size is the length of the string. The variable is passed by reference, so the string is not copied; give it a variable, not an expression.

A file posted by the browser โ€” the page sent a multipart/form-data request, for instance with a FormData object; $fieldName is the name of the file field:

 1use FR\Drive\Files\DataSource\UploadDataSource;
 2
 3$dataSource = new UploadDataSource('file');

The upload is checked as the object is built: a missing field or a PHP upload error (too large for upload_max_filesize, partial upload, no temporary folder) throws right away, with the message to show. The size is the one PHP reports for the upload, and the file is moved from PHP's temporary folder, not copied.

A stream โ€” the bytes come from an open resource: a php://input handle for a raw request body, a stream from an HTTP client, a fopen() handle of something outside the user's files:

 1use FR\Drive\Files\DataSource\StreamDataSource;
 2
 3$dataSource = new StreamDataSource(fopen('php://input', 'rb'));

It throws unless it is given a resource. The size is not known in advance, so the quota is not checked before writing; the number of bytes copied ends up in $dataSource->bytesWritten. The source stream is closed after the copy. An optional second argument sets the mode the target is opened with (wb by default; ab appends).

An existing file on disk โ€” a file your plugin made in a temporary folder, for example the output of a converter:

 1use FR\Drive\Files\DataSource\CopyDataSource;
 2use FR\Drive\Files\DataSource\MoveDataSource;
 3
 4$dataSource = new CopyDataSource($tempFullPath); // leaves the source in place
 5$dataSource = new MoveDataSource($tempFullPath); // removes the source

The size is the size of the source file. Prefer MoveDataSource for a temporary file you would delete anyway.

Two base classes in FR\Drive\Files\Handlers\ do the common cases for you, as helper classes in the plugin's folder:

 1class Save extends AbstractUpdateByUpload {
 2
 3	protected string $uploadName = 'file';
 4
 5}

is a complete save: $handler->getSubClass('Save')->run($pathInfo) replaces the file with the upload posted in the file field and returns the message. And

 1class CreateBlank extends AbstractCreateBlank {
 2
 3	public string $defaultStringContents = "Write your note here.\n";
 4
 5}

is a complete "create new" endpoint: $handler->getSubClass('CreateBlank')->run() reads the posted fileName, creates the file in the folder given in paths[], and fills the response, including the update that adds the new row to the file list. Override getDataSource(Folder $folder, string $fileName): DataSource to start from something other than a fixed string.

An editor must know whether the user may save, to open read-only otherwise:

 1use FR\Drive\Files\Actions\Alter\Update\UpdatePerms;
 2
 3$isEditable = UpdatePerms::canUpdateFile($pathInfo, false);

The second argument false asks for a boolean instead of an exception. The server refuses the save anyway for a user without the permission; the check only decides what the page shows.

JavaScript and CSS

FileRun serves a plugin's scripts and stylesheets from these locations:

apps/{App}/!public/js/min/{name}.js.php     named '{App}/{name}' in scripts
apps/{App}/!public/css/min/{name}.css.php   named '{App}/{name}' in styles

The file is plain JavaScript or CSS โ€” the .php ending is only part of the name (FileRun's build tools write these files; a plugin written by hand simply saves the file under that name). It must not contain the character sequences <? or ?>. A sub-folder in the name is allowed: '{App}/editor/app' is js/min/editor/app.js.php.

Anything else under apps/{App}/!public/ โ€” a third-party library, an ES module, fonts, images โ€” is served by the web server as a static file and is referenced by its absolute URL:

 1'scripts' => [
 2	'MyPlugins/viewer',
 3	FR::$url->root.'/apps/MyPlugins/!public/vendor/library.min.js'
 4]

Things the page's script should do:

Example 1: a viewer that reads a file

A plugin named TextViewer in an app named MyPlugins: it reads a text file on the server, shows it in the popup with its line count, and adds a close button. Six files.

apps/MyPlugins/!classes/Files/Handlers/Handlers/TextViewer/TextViewer.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\TextViewer;
 5
 6use FR\Drive\Files\Handlers\AbstractHandler;
 7
 8class TextViewer extends AbstractHandler {
 9
10	public bool $online = false;
11	public string $title = 'Text Viewer';
12	public ?string $iconCls = 'fa-file-lines';
13	public null|array|bool $supportedExtensions = ['txt', 'log', 'ini'];
14
15}

apps/MyPlugins/!classes/Files/Handlers/Handlers/TextViewer/HandlerSettings.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\TextViewer;
 5
 6use FR\Drive\Files\Handlers\AbstractHandlerSettings;
 7
 8class HandlerSettings extends AbstractHandlerSettings {
 9
10	public array $ui = [
11		'requires' => [
12			'download'
13		],
14		'width' => 700,
15		'height' => 500
16	];
17
18}

apps/MyPlugins/ui/handlers/handlers/!includes/!init.php

 1<?php
 2declare(strict_types = 1);
 3
 4require FR::$path->apps.'/Drive/ui/handlers/handlers/!includes/!init.php';

apps/MyPlugins/ui/handlers/handlers/TextViewer/index.php

 1<?php
 2declare(strict_types = 1);
 3
 4use FR\Core\S;
 5
 6use FR\Drive\Files\Actions\Read\FileReadWithHandler;
 7use FR\Drive\Files\Actions\Read\ReadFileContents;
 8
 9$pathInfo = $handler->getFirstItem();
10
11$read = FileReadWithHandler::byPathInfo($pathInfo, $handler, $version);
12$text = ReadFileContents::getContents($read);
13
14$lineCount = substr_count(rtrim($text, "\n"), "\n") + 1;
15
16$handler->page([
17	'styles' => [
18		'Core/basic',
19		'MyPlugins/plugins'
20	],
21	'scripts' => [
22		'MyPlugins/TextViewer'
23	],
24	'loadMsg' => false,
25	'jsGlobals' => [
26		'FR.vars' => [
27			'text' => $text
28		]
29	],
30	'contents' =>
31		'<div class="toolbar">'.
32			'<span id="status">'.S::forHTML($handler->t('%1 lines', [$lineCount])).'</span>'.
33			'<button id="close" type="button">'.S::forHTML($handler->t('Close')).'</button>'.
34		'</div>'.
35		'<pre id="contents"></pre>'
36]);

FR\Core\S::forHTML() escapes text for HTML.

apps/MyPlugins/!public/js/min/TextViewer.js.php

 1document.addEventListener('DOMContentLoaded', () => {
 2	document.getElementById('contents').textContent = FR.vars.text;
 3	document.getElementById('close').addEventListener('click', closeWindow);
 4});
 5
 6function closeWindow() {
 7	if (window.parent?.FR?.isMainUI) {
 8		if (FR.windowId && window.parent.FR.UI.popups[FR.windowId]) {
 9			return window.parent.FR.UI.popups[FR.windowId].close();
10		}
11		if (window.parent.FR.UI.FileViewer?.isVisible()) {
12			return window.parent.FR.UI.FileViewer.hide();
13		}
14	} else {
15		window.close();
16	}
17}

apps/MyPlugins/!public/css/min/plugins.css.php (shared with the second example)

 1body {
 2	margin: 0;
 3	height: 100vh;
 4	display: flex;
 5	flex-direction: column;
 6}
 7.toolbar {
 8	padding: 8px;
 9	display: flex;
10	gap: 8px;
11	align-items: center;
12}
13#status {
14	margin-left: auto;
15}
16#contents, #editor {
17	flex: 1;
18	margin: 0 8px 8px;
19	overflow: auto;
20	font: 13px monospace;
21	color: inherit;
22	background: transparent;
23	white-space: pre;
24	border: 1px solid var(--theme-border, #ccc);
25	border-radius: 4px;
26	padding: 8px;
27	resize: none;
28}

Save the files, reload FileRun, right-click a .txt file: Open with.. now lists "Text Viewer".

Example 2: an editor that writes a file

A plugin named NoteEditor in the same app: it opens .txt files in a text area, saves them, opens read-only for users who may not change the file, and adds "Note" to the New button. It shares the !init.php and the stylesheet of the first example. Eight files.

apps/MyPlugins/!classes/Files/Handlers/Handlers/NoteEditor/NoteEditor.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\NoteEditor;
 5
 6use FR\Drive\Files\Handlers\AbstractHandler;
 7
 8class NoteEditor extends AbstractHandler {
 9
10	public bool $online = false;
11	public string $title = 'Note Editor';
12	public ?string $iconCls = 'fa-pen';
13	public null|array|bool $supportedExtensions = ['txt'];
14
15}

apps/MyPlugins/!classes/Files/Handlers/Handlers/NoteEditor/HandlerSettings.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\NoteEditor;
 5
 6use FR\Drive\Files\Handlers\AbstractHandlerSettings;
 7
 8class HandlerSettings extends AbstractHandlerSettings {
 9
10	public function ui(): array {
11		return [
12			'requires' => [
13				'download'
14			],
15			'createNew' => [
16				'title' => $this->handler->t('Note'),
17				'defaultFileName' => $this->handler->t('New note.txt'),
18				'iconCls' => 'fa-pen',
19				'requires' => [
20					'downloadFiles'
21				]
22			],
23			'width' => 700,
24			'height' => 500
25		];
26	}
27
28}

apps/MyPlugins/!classes/Files/Handlers/Handlers/NoteEditor/Save.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\NoteEditor;
 5
 6use FR\Drive\Files\Handlers\AbstractUpdateByUpload;
 7
 8class Save extends AbstractUpdateByUpload {
 9
10	protected string $uploadName = 'file';
11
12}

apps/MyPlugins/!classes/Files/Handlers/Handlers/NoteEditor/CreateBlank.php

 1<?php
 2declare(strict_types = 1);
 3
 4namespace FR\MyPlugins\Files\Handlers\Handlers\NoteEditor;
 5
 6use FR\Drive\Files\Handlers\AbstractCreateBlank;
 7
 8class CreateBlank extends AbstractCreateBlank {
 9
10	public string $defaultStringContents = "Write your note here.\n";
11
12}

apps/MyPlugins/ui/handlers/handlers/NoteEditor/index.php

 1<?php
 2declare(strict_types = 1);
 3
 4use FR\Core\S;
 5
 6use FR\Drive\Files\Actions\Alter\Update\UpdatePerms;
 7use FR\Drive\Files\Actions\Read\FileReadWithHandler;
 8use FR\Drive\Files\Actions\Read\ReadFileContents;
 9
10$pathInfo = $handler->getFirstItem();
11
12$read = FileReadWithHandler::byPathInfo($pathInfo, $handler, $version);
13$text = ReadFileContents::getContents($read);
14
15$isEditable = UpdatePerms::canUpdateFile($pathInfo, false);
16
17$handler->page([
18	'styles' => [
19		'Core/basic',
20		'MyPlugins/plugins'
21	],
22	'scripts' => [
23		'MyPlugins/NoteEditor'
24	],
25	'loadMsg' => false,
26	'jsGlobals' => [
27		'FR.vars' => [
28			'text' => $text,
29			'fileName' => $pathInfo->fileName,
30			'isEditable' => $isEditable,
31			'saveURL' => $handler->getURL('!actions/save', [
32				'paths' => [$pathInfo->relativePath]
33			]),
34			'labels' => [
35				'readOnly' => $handler->t('Read only'),
36				'unsaved' => $handler->t('Unsaved changes'),
37				'saving' => $handler->t('Saving...'),
38				'failed' => $handler->t('Saving failed'),
39				'discard' => $handler->t('Discard the changes made?')
40			]
41		]
42	],
43	'contents' =>
44		'<div class="toolbar">'.
45			'<button id="save" type="button" disabled>'.S::forHTML($handler->t('Save')).'</button>'.
46			'<button id="saveAndClose" type="button" disabled>'.S::forHTML($handler->t('Save and close')).'</button>'.
47			'<button id="close" type="button">'.S::forHTML($handler->t('Close')).'</button>'.
48			'<span id="status"></span>'.
49		'</div>'.
50		'<textarea id="editor"></textarea>'
51]);

apps/MyPlugins/ui/handlers/handlers/NoteEditor/!actions/save.php

 1<?php
 2declare(strict_types = 1);
 3
 4use FR\Core\Response\Response;
 5
 6use FR\Drive\Files\Presentation\UI\Updates;
 7
 8$pathInfo = $handler->getFirstItem();
 9
10$save = $handler->getSubClass('Save');
11$message = $save->run($pathInfo);
12
13Response::addMessage($message);
14Response::addUpdate($pathInfo->relativePath, [
15	'refresh' => Updates::getUpdatedDetails($pathInfo)
16]);
17Response::markSuccessful();

apps/MyPlugins/ui/handlers/handlers/NoteEditor/!actions/create_blank.php

 1<?php
 2declare(strict_types = 1);
 3
 4$handler->getSubClass('CreateBlank')->run();

apps/MyPlugins/!public/js/min/NoteEditor.js.php

 1let changesSaved = true;
 2
 3document.addEventListener('DOMContentLoaded', () => {
 4	const editor = document.getElementById('editor');
 5	editor.value = FR.vars.text;
 6	document.getElementById('close').addEventListener('click', closeWindow);
 7	if (!FR.vars.isEditable) {
 8		editor.readOnly = true;
 9		setStatus(FR.vars.labels.readOnly);
10		return;
11	}
12	document.getElementById('save').disabled = false;
13	document.getElementById('saveAndClose').disabled = false;
14	document.getElementById('save').addEventListener('click', () => save(false));
15	document.getElementById('saveAndClose').addEventListener('click', () => save(true));
16	editor.addEventListener('input', () => {
17		changesSaved = false;
18		setStatus(FR.vars.labels.unsaved);
19	});
20	window.onbeforeunload = () => changesSaved ? undefined : FR.vars.labels.discard;
21});
22
23function setStatus(text) {
24	document.getElementById('status').textContent = text;
25}
26
27async function save(closeAfter) {
28	const form = new FormData();
29	form.append('csrf', FR.csrf);
30	form.append('file', new Blob([document.getElementById('editor').value]), FR.vars.fileName);
31	setStatus(FR.vars.labels.saving);
32	let rs;
33	try {
34		const response = await fetch(FR.vars.saveURL, {
35			method: 'POST',
36			headers: {'Accept': 'application/json'},
37			body: form
38		});
39		rs = await response.json();
40	} catch (e) {
41		setStatus(FR.vars.labels.failed);
42		return;
43	}
44	if (!rs.success) {
45		setStatus((rs.msg || [FR.vars.labels.failed]).join(' '));
46		return;
47	}
48	changesSaved = true;
49	setStatus('');
50	if (window.parent?.FR?.isMainUI) {
51		if (rs.msg) {
52			window.parent.Ext.feedback(rs.msg, 'success');
53		}
54		if (rs.updates) {
55			window.parent.FR.utils.applyBatchFileUpdates(rs.updates);
56		}
57	}
58	if (closeAfter) {
59		closeWindow();
60	}
61}
62
63function closeWindow() {
64	if (window.parent?.FR?.isMainUI) {
65		if (FR.windowId && window.parent.FR.UI.popups[FR.windowId]) {
66			return window.parent.FR.UI.popups[FR.windowId].close();
67		}
68		if (window.parent.FR.UI.FileViewer?.isVisible()) {
69			return window.parent.FR.UI.FileViewer.hide();
70		}
71	} else {
72		window.close();
73	}
74}

What to expect after a reload of FileRun: "Note Editor" under Open with.. on .txt files, "Note" under the New button, a saved file whose row in the file list updates without a refresh, and โ€” for a user who received the file through a share with the viewer role โ€” the same page with a read-only text area and the save buttons disabled.

Translating your plugin

Every string passed through $handler->t() on the server, or Ext.T() in a page that loads Core/ext, is looked up in the section File Plugins/{title} of the active language. English needs no translation file: the string itself is the English text. A translation is a PHP file returning an array, placed at

customizables/translations/{language}/File Plugins/Note Editor.php
 1<?php
 2return [
 3	'Note' => 'Notiศ›ฤƒ',
 4	'New note.txt' => 'Notiศ›ฤƒ nouฤƒ.txt',
 5	'Save and close' => 'Salveazฤƒ ศ™i รฎnchide'
 6];

See Translating FileRun for the language names.

Checklist

  1. {Id}.php with $title, $iconCls, and $supportedExtensions or $supportedTypes.
  2. HandlerSettings.php with requires; createNew if the plugin can create files; getFields() if it needs configuration.
  3. ui/handlers/handlers/!includes/!init.php, for a new app.
  4. index.php: read through FileReadWithHandler (or hand the browser getOpenInBrowserURL()), decide read-only with UpdatePerms::canUpdateFile(), render with $handler->page().
  5. !actions/ endpoints that call the helper classes and answer through Response.
  6. Save extends AbstractUpdateByUpload and CreateBlank extends AbstractCreateBlank where they fit.
  7. The script and the stylesheet under !public/js/min/ and !public/css/min/, with .js.php / .css.php names.
  8. Test: the plugin appears under Open with..; the popup opens; a user with download but without edit permission gets a read-only page; create โ†’ edit โ†’ save โ†’ reopen keeps the contents; the same works through a shared link.

What plugins cannot do

Questions about the plugin interface are welcome through the contact form.