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.
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.
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.
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.
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.
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.
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.
declare(strict_types = 1); and uses namespaces.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.apps/Autodesk (a complete plugin: the Autodesk viewer, with a
background conversion job and control panel settings) and
apps/Example (a custom theme).{App} is your app folder name (the examples
use MyPlugins), {Id} is the plugin's class name (for example
NoteEditor). 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.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.
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 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.
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).
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; throw FR\Core\PublicException
with 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 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:
$handler โ the plugin object. It throws if isEnabled() is false.$handler->pathInfos โ one FR\Drive\PathInfo\PathInfo object per
entry of the paths[] request parameter, keyed by path.
$handler->getFirstItem() returns the first.$version โ the version request parameter, 0 for 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.
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 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.
$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.
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:
FR.vars (or whatever you named in jsGlobals) inside a
DOMContentLoaded listener.!actions endpoint, as shown
above.updates to the file
manager and show the message there: when the page runs inside FileRun's
popup, window.parent.FR.isMainUI is true, and
window.parent.FR.utils.applyBatchFileUpdates(rs.updates) and
window.parent.Ext.feedback(rs.msg, 'success') are available.closeWindow() function in the examples below does it and also works
when the plugin is opened in its own tab.Core/basic sets the fr-is-dark class 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.window.onbeforeunload.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".
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.
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.
{Id}.php with $title, $iconCls, and $supportedExtensions or
$supportedTypes.HandlerSettings.php with requires; createNew if the plugin can
create files; getFields() if it needs configuration.ui/handlers/handlers/!includes/!init.php, for a new app.index.php: read through FileReadWithHandler (or hand the browser
getOpenInBrowserURL()), decide read-only with
UpdatePerms::canUpdateFile(), render with $handler->page().!actions/ endpoints that call the helper classes and answer through
Response.Save extends AbstractUpdateByUpload and CreateBlank extends AbstractCreateBlank where they fit.!public/js/min/ and
!public/css/min/, with .js.php / .css.php names.$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.