Configure
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.
- An entry for an extension wins over an entry for the type the extension belongs to.
- When adding an entry you can type any file extension, including one FileRun does not know yet.
Enabling, disabling and configuring a plugin
Open the control panel, Files โ Plugins, select the plugin and
click Edit. Every plugin has these options:
Hide pluginโ removes it from all menus.Open in a new browser tabโ opens the plugin in a new tab instead of a popup window inside FileRun. Some plugins always open in a new tab; for those the option is shown checked and cannot be changed.Hide option for creating new filesโ removes the plugin's entry from the New button (shown only for plugins that can create files).
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
- A plugin is written in PHP (8.3 or newer โ the version FileRun itself
requires), plus the JavaScript and CSS of its page. Every PHP file
starts with
declare(strict_types = 1);and uses namespaces. - The plugin lives in its own folder under
apps/. FileRun's updates replace and remove FileRun's own files only, so a folder with your own name is left alone. Keep a copy of it anyway: replacing the whole installation folder would remove it. - FileRun ships two unencoded apps you can read as real examples:
apps/Autodesk(a complete plugin: the Autodesk viewer, with a background conversion job and control panel settings) andapps/Example(a custom theme). - Names used on this page:
{App}is your app folder name (the examples useMyPlugins),{Id}is the plugin's class name (for exampleNoteEditor). Together they form the plugin's identifier{App}.{Id}, which is what the control panel, the settings and the URLs use. It must be at most 50 characters long.
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:
1apps/{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:
public ?array $descriptionโ paragraphs shown above the form.public function onSettingsSubmitted(array $newValues): voidโ called before the values are saved; throwFR\Core\PublicExceptionwith a message to refuse them.public function getMoreInfo(): stringโ HTML shown below the form.
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
1{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:
$handlerโ the plugin object. It throws ifisEnabled()is false.$handler->pathInfosโ oneFR\Drive\PathInfo\PathInfoobject per entry of thepaths[]request parameter, keyed by path.$handler->getFirstItem()returns the first.$versionโ theversionrequest parameter,0for the current contents of the file (older versions are numbered).
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:
1apps/{App}/!public/js/min/{name}.js.php named '{App}/{name}' in scripts
2apps/{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:
- Read
FR.vars(or whatever you named injsGlobals) inside aDOMContentLoadedlistener. - Send the CSRF token with every call to an
!actionsendpoint, as shown above. - After a successful save, hand the answer's
updatesto the file manager and show the message there: when the page runs inside FileRun's popup,window.parent.FR.isMainUIis true, andwindow.parent.FR.utils.applyBatchFileUpdates(rs.updates)andwindow.parent.Ext.feedback(rs.msg, 'success')are available. - Close the popup from the parent window, not the page โ the
closeWindow()function in the examples below does it and also works when the plugin is opened in its own tab. - For dark mode,
Core/basicsets thefr-is-darkclass on<html>and defines FileRun's colours as CSS variables; the examples' stylesheet inherits the body colours and needs no rules of its own for it. - Warn about unsaved changes with
window.onbeforeunload.
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
1customizables/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
{Id}.phpwith$title,$iconCls, and$supportedExtensionsor$supportedTypes.HandlerSettings.phpwithrequires;createNewif the plugin can create files;getFields()if it needs configuration.ui/handlers/handlers/!includes/!init.php, for a new app.index.php: read throughFileReadWithHandler(or hand the browsergetOpenInBrowserURL()), decide read-only withUpdatePerms::canUpdateFile(), render with$handler->page().!actions/endpoints that call the helper classes and answer throughResponse.Save extends AbstractUpdateByUploadandCreateBlank extends AbstractCreateBlankwhere they fit.- The script and the stylesheet under
!public/js/min/and!public/css/min/, with.js.php/.css.phpnames. - 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
- Locking. Nothing stops two people from editing the same file at the same time through a plugin; the last save wins. (The office integrations are the exception: the office servers implement their own locking.)
- Thumbnails. FileRun's thumbnails do not know about plugins; a plugin cannot add thumbnails for a file format of its own.
- New file types. FileRun's list of known extensions (icon,
description, type grouping) is part of FileRun itself. A plugin can
still accept an unknown extension through
$supportedExtensions, and the administrator can make it the default for that extension, as described above.
Questions about the plugin interface are welcome through the contact form.