diff --git a/__tests__/fileAction.spec.ts b/__tests__/fileAction.spec.ts
new file mode 100644
index 00000000..6f73dc52
--- /dev/null
+++ b/__tests__/fileAction.spec.ts
@@ -0,0 +1,193 @@
+import { getFileActions, registerFileAction, FileAction } from '../lib/fileAction'
+import logger from '../lib/utils/logger';
+
+declare global {
+ interface Window {
+ OC: any;
+ _nc_fileactions: FileAction[];
+ }
+}
+
+describe('FileActions init', () => {
+ test('Getting empty uninitialized FileActions', () => {
+ logger.debug = jest.fn()
+ const fileActions = getFileActions()
+ expect(window._nc_fileactions).toBeUndefined()
+ expect(fileActions).toHaveLength(0)
+ expect(logger.debug).toHaveBeenCalledTimes(0)
+ })
+
+ test('Initializing FileActions', () => {
+ logger.debug = jest.fn()
+ const action = new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ })
+
+ expect(action.id).toBe('test')
+ expect(action.displayName([])).toBe('Test')
+ expect(action.iconSvgInline([])).toBe('')
+
+ registerFileAction(action)
+
+ expect(window._nc_fileactions).toHaveLength(1)
+ expect(getFileActions()).toHaveLength(1)
+ expect(getFileActions()[0]).toStrictEqual(action)
+ expect(logger.debug).toHaveBeenCalled()
+ })
+
+ test('Duplicate FileAction gets rejected', () => {
+ logger.error = jest.fn()
+ const action = new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ })
+
+ registerFileAction(action)
+ expect(getFileActions()).toHaveLength(1)
+ expect(getFileActions()[0]).toStrictEqual(action)
+
+ const action2 = new FileAction({
+ id: 'test',
+ displayName: () => 'Test 2',
+ iconSvgInline: () => '',
+ exec: () => true,
+ })
+
+ registerFileAction(action2)
+ expect(getFileActions()).toHaveLength(1)
+ expect(getFileActions()[0]).toStrictEqual(action)
+ expect(logger.error).toHaveBeenCalledWith('FileAction test already registered', { action: action2 })
+ })
+})
+
+describe('Invalid FileAction creation', () => {
+ test('Invalid id', () => {
+ expect(() => {
+ new FileAction({
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ } as any as FileAction)
+ }).toThrowError('Invalid id')
+ })
+ test('Invalid displayName', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ } as any as FileAction)
+ }).toThrowError('Invalid displayName function')
+ })
+ test('Invalid iconSvgInline', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: '',
+ exec: () => true,
+ } as any as FileAction)
+ }).toThrowError('Invalid iconSvgInline function')
+ })
+ test('Invalid exec', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: false,
+ } as any as FileAction)
+ }).toThrowError('Invalid exec function')
+ })
+ test('Invalid enabled', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ enabled: false,
+ } as any as FileAction)
+ }).toThrowError('Invalid enabled function')
+ })
+ test('Invalid execBatch', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ execBatch: false,
+ } as any as FileAction)
+ }).toThrowError('Invalid execBatch function')
+ })
+ test('Invalid order', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ order: 'invalid',
+ } as any as FileAction)
+ }).toThrowError('Invalid order')
+ })
+ test('Invalid default', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ default: 'invalid',
+ } as any as FileAction)
+ }).toThrowError('Invalid default')
+ })
+ test('Invalid inline', () => {
+ expect(() => {
+ new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ inline: true,
+ } as any as FileAction)
+ }).toThrowError('Invalid inline function')
+ })
+})
+
+describe('FileActions creation', () => {
+ test('create valid FileAction', () => {
+ logger.debug = jest.fn()
+ const action = new FileAction({
+ id: 'test',
+ displayName: () => 'Test',
+ iconSvgInline: () => '',
+ exec: () => true,
+ execBatch: () => true,
+ enabled: () => true,
+ order: 100,
+ default: true,
+ inline: (mount) => mount.append('test'),
+ })
+
+ const mount = document.createElement('div')
+
+ expect(action.id).toBe('test')
+ expect(action.displayName([])).toBe('Test')
+ expect(action.iconSvgInline([])).toBe('')
+ expect(action.exec({} as any)).toBe(true)
+ expect(action.execBatch?.([])).toBe(true)
+ expect(action.enabled?.({} as any, {})).toBe(true)
+ expect(action.order).toBe(100)
+ expect(action.default).toBe(true)
+ action.inline?.(mount)
+ expect(mount.innerHTML).toBe('test')
+ })
+})
\ No newline at end of file
diff --git a/lib/fileAction.ts b/lib/fileAction.ts
new file mode 100644
index 00000000..dd1002a3
--- /dev/null
+++ b/lib/fileAction.ts
@@ -0,0 +1,129 @@
+/**
+ * @copyright Copyright (c) 2021 John Molakvoæ
+ *
+ * @author John Molakvoæ
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import { Node } from "./files/node"
+import logger from "./utils/logger"
+
+interface FileActionData {
+ /** Unique ID */
+ id: string
+ /** Translatable string displayed in the menu */
+ displayName: (files: Node[]) => string
+ /** Svg as inline string. */
+ iconSvgInline: (files: Node[]) => string
+ // Condition wether this action is shown or not
+ enabled?: (files: Node[], view) => boolean
+ /** Function executed on single file action
+ * @returns true if the action was executed, false otherwise
+ * @throws Error if the action failed
+ */
+ exec: (file: Node) => boolean,
+ /** Function executed on multiple files action
+ * @returns true if the action was executed, false otherwise
+ * @throws Error if the action failed
+ */
+ execBatch?: (files: Node[]) => boolean
+ /** This action order in the list */
+ order?: number,
+ /** Make this action the default */
+ default?: boolean,
+ /** If defined, will provide a mount point
+ * to render the action inline.
+ */
+ inline?: (mount: HTMLElement) => void,
+}
+
+// Allow class/interface merging and proxying
+export interface FileAction extends FileActionData {}
+export class FileAction {
+ private _action: FileActionData
+
+ constructor(action: FileActionData) {
+ this.validateAction(action)
+ this._action = action
+
+ // Forward any getter to action data
+ return new Proxy(this, {
+ get(target, property: string) {
+ return Reflect.get(target._action, property)
+ }
+ })
+ }
+
+ private validateAction(action: FileActionData) {
+ if (!action.id || typeof action.id !== 'string') {
+ throw new Error('Invalid id')
+ }
+
+ if (!action.displayName || typeof action.displayName !== 'function') {
+ throw new Error('Invalid displayName function')
+ }
+
+ if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') {
+ throw new Error('Invalid iconSvgInline function')
+ }
+
+ if (!action.exec || typeof action.exec !== 'function') {
+ throw new Error('Invalid exec function')
+ }
+
+ // Optional properties --------------------------------------------
+ if ('enabled' in action && typeof action.execBatch !== 'function') {
+ throw new Error('Invalid enabled function')
+ }
+
+ if ('execBatch' in action && typeof action.execBatch !== 'function') {
+ throw new Error('Invalid execBatch function')
+ }
+
+ if ('order' in action && typeof action.order !== 'number') {
+ throw new Error('Invalid order')
+ }
+
+ if ('default' in action && typeof action.default !== 'boolean') {
+ throw new Error('Invalid default')
+ }
+
+ if ('inline' in action && typeof action.inline !== 'function') {
+ throw new Error('Invalid inline function')
+ }
+ }
+}
+
+export const registerFileAction = function(action: FileAction): void {
+ if (typeof window._nc_fileactions === 'undefined') {
+ window._nc_fileactions = []
+ logger.debug('FileActions initialized')
+ }
+
+ // Check duplicates
+ if (window._nc_fileactions.find(search => search.id === action.id)) {
+ logger.error(`FileAction ${action.id} already registered`, { action })
+ return
+ }
+
+ window._nc_fileactions.push(action)
+}
+
+export const getFileActions = function(): FileAction[] {
+ return window._nc_fileactions || []
+}
diff --git a/lib/index.ts b/lib/index.ts
index 8cd3be86..85ca825a 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -23,6 +23,7 @@
export { formatFileSize } from './humanfilesize'
export { type Entry } from './newFileMenu'
+import { FileAction } from './fileAction'
import { type Entry, getNewFileMenu, NewFileMenu } from './newFileMenu'
export { FileType } from './files/fileType'
@@ -35,6 +36,7 @@ declare global {
interface Window {
OC: any;
_nc_newfilemenu: NewFileMenu;
+ _nc_fileactions: FileAction[];
}
}