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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ testem.log
# System files
.DS_Store
Thumbs.db

# Generated extensions config
src/app/extensions.config.ts
136 changes: 136 additions & 0 deletions docs/file-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# File Extensions

Plugin architecture for adding custom actions to the file browser.

## Overview

Extensions are placed in `src/app/extensions/` and managed via `extensions.config.ts`.

## FileActionExtension

```typescript
interface FileActionExtension {
id: string;
label: string;
icon: string;
command: (ctx: FileActionContext) => void;
parentId?: string;
position?: 'start' | 'end' | number;
visible?: (ctx: FileActionContext) => boolean;
disabled?: (ctx: FileActionContext) => boolean;
}
```

### parentId

If `parentId` is set, the extension appears only in that submenu (e.g., `'share'`).
If not set, the extension appears in both menu and toolbar.

### FileActionContext

```typescript
interface FileActionContext {
target: FileModel | FileDetailsModel | FileFolderModel;
location: 'file-list' | 'file-detail';
isViewOnly: boolean;
canWrite: boolean;
}
```

## Example: Submenu Extension

```typescript
export function copyLinksExtensionFactory(): FileActionExtension[] {
return [
{
id: 'copy-link',
label: 'Copy Link',
icon: 'fas fa-link',
command: (ctx) => {
navigator.clipboard.writeText(ctx.target.links.html);
},
parentId: 'share', // appears in Share submenu only
position: 'end',
visible: (ctx) => ctx.target.kind === 'file',
disabled: (ctx) => ctx.isViewOnly,
},
];
}
```

> **Note:** Context menus honor `position` exactly (for example, `'start'` inserts before built-in items). Top-level toolbars append extensions after the core OSF buttons but still keep extension-to-extension ordering based on `position`. Design UX with that constraint in mind.

## Example: Menu + Toolbar Extension

```typescript
export function createFileExtensionFactory(config: Config): FileActionExtension[] {
return [
{
id: 'create-file',
label: 'Create File',
icon: 'fas fa-file-plus',
command: (ctx) => {
window.open(`${config.editorUrl}?path=${ctx.target.path}`, '_blank');
},
// no parentId: appears in both menu and toolbar
position: 'end',
visible: (ctx) =>
ctx.location === 'file-list' &&
ctx.target.kind === 'folder' &&
!ctx.isViewOnly &&
ctx.canWrite,
},
];
}
```

## Configuration

The extension configuration is managed via `extensions.config.ts`, which is generated at build time.

### File Structure

```
src/app/
extensions.config.ts # Generated (not in git)
extensions.config.default.ts # Default config (in git)
```

### Build-time Configuration

By default, `extensions.config.default.ts` is used. To use a custom configuration:

```bash
EXTENSIONS_CONFIG=/path/to/my-extensions.config.ts npm run build
```

The `scripts/setup-extensions.js` helper runs before `npm run build`, `npm start`, and `npm test` (see the `prebuild` / `prestart` / `pretest` npm scripts) and handles this:
- If `EXTENSIONS_CONFIG` is set, copies that file
- Otherwise, copies `extensions.config.default.ts`

## Registration

### extensions.config.ts

```typescript
export const extensionConfig: ExtensionConfig[] = [
{
load: () => import('./extensions/copy-links'),
factory: 'copyLinksExtensionFactory',
enabled: true,
},
{
load: () => import('./extensions/onlyoffice'),
factory: 'editByOnlyOfficeExtensionFactory',
enabled: true,
config: { editorUrl: 'https://...' },
},
{
load: () => import('./extensions/onlyoffice'),
factory: 'createFileExtensionFactory',
enabled: true,
config: { editorUrl: 'https://...' },
},
];
```

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"scripts": {
"ng": "ng",
"analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks",
"prebuild": "node scripts/setup-extensions.js",
"prestart": "node scripts/setup-extensions.js",
"build": "ng build",
"check:config": "node ./docker/check-config.js",
"ci:test": "jest",
Expand All @@ -23,6 +25,7 @@
"start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker",
"start:test": "ng serve --configuration test-osf",
"start:test:future": "ng serve --configuration test",
"pretest": "node scripts/setup-extensions.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage && npm run test:display",
Expand Down
18 changes: 18 additions & 0 deletions scripts/setup-extensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const fs = require('fs');
const path = require('path');

const targetPath = path.join(__dirname, '../src/app/extensions.config.ts');
const defaultPath = path.join(__dirname, '../src/app/extensions.config.default.ts');
const customPath = process.env.EXTENSIONS_CONFIG;

if (customPath) {
const resolvedPath = path.resolve(customPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`EXTENSIONS_CONFIG file not found: ${resolvedPath}`);
}
fs.copyFileSync(resolvedPath, targetPath);
console.log(`Extensions config: ${resolvedPath}`);
} else if (!fs.existsSync(targetPath)) {
fs.copyFileSync(defaultPath, targetPath);
console.log(`Extensions config: ${defaultPath} (default)`);
}
4 changes: 4 additions & 0 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { authInterceptor } from '@core/interceptors/auth.interceptor';
import { errorInterceptor } from '@core/interceptors/error.interceptor';
import { viewOnlyInterceptor } from '@core/interceptors/view-only.interceptor';
import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/provider/application.initialization.provider';
import { EXTENSION_INITIALIZATION_PROVIDER } from '@core/provider/extension.initialization.provider';
import { SENTRY_PROVIDER } from '@core/provider/sentry.provider';

import CustomPreset from './core/theme/custom-preset';
Expand Down Expand Up @@ -53,5 +54,8 @@ export const appConfig: ApplicationConfig = {
provideStore(STATES),
provideZoneChangeDetection({ eventCoalescing: true }),
SENTRY_PROVIDER,

// Dynamic Extension Loading (configured in extensions.config.ts)
EXTENSION_INITIALIZATION_PROVIDER,
],
};
36 changes: 36 additions & 0 deletions src/app/core/provider/extension.initialization.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { APP_INITIALIZER, Provider } from '@angular/core';

import { ExtensionRegistry } from '@core/services/extension-registry.service';
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

import { ExtensionConfig, extensionConfig } from '../../extensions.config';

type FactoryFunction = (config?: unknown) => FileActionExtension[];

async function loadExtensions(registry: ExtensionRegistry): Promise<void> {
for (const ext of extensionConfig) {
if (!ext.enabled) continue;
await loadExtension(ext, registry);
}
}

async function loadExtension(extConfig: ExtensionConfig, registry: ExtensionRegistry): Promise<void> {
const module = await extConfig.load();
const factory = (module as Record<string, FactoryFunction>)[extConfig.factory];

if (typeof factory !== 'function') {
throw new Error(`Extension factory "${extConfig.factory}" not found in module`);
}

const extensions = factory(extConfig.config);
registry.register(extensions);
}

export const EXTENSION_INITIALIZATION_PROVIDER: Provider = {
provide: APP_INITIALIZER,
useFactory: (registry: ExtensionRegistry) => {
return () => loadExtensions(registry);
},
deps: [ExtensionRegistry],
multi: true,
};
34 changes: 34 additions & 0 deletions src/app/core/services/extension-registry.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';

import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

import { ExtensionRegistry } from './extension-registry.service';

describe('ExtensionRegistry', () => {
let service: ExtensionRegistry;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ExtensionRegistry);
});

it('registers extensions cumulatively', () => {
const extA: FileActionExtension = {
id: 'a',
label: 'A',
icon: 'a',
command: jest.fn(),
};
const extB: FileActionExtension = {
id: 'b',
label: 'B',
icon: 'b',
command: jest.fn(),
};

service.register([extA]);
service.register([extB]);

expect(service.extensions()).toEqual([extA, extB]);
});
});
19 changes: 19 additions & 0 deletions src/app/core/services/extension-registry.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Injectable, signal } from '@angular/core';

import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

/**
* Central registry for all plugin extensions.
* Extensions are registered at app initialization time via APP_INITIALIZER.
*/
@Injectable({ providedIn: 'root' })
export class ExtensionRegistry {
private readonly _extensions = signal<FileActionExtension[]>([]);

/** Read-only signal of all extensions */
readonly extensions = this._extensions.asReadonly();

register(extensions: FileActionExtension[]): void {
this._extensions.update((current) => [...current, ...extensions]);
}
}
20 changes: 20 additions & 0 deletions src/app/extensions.config.default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Extension Configuration
*
* See docs/file-extensions.md for details.
*/
export interface ExtensionConfig {
load: () => Promise<unknown>;
factory: string;
enabled: boolean;
config?: unknown;
}

export const extensionConfig: ExtensionConfig[] = [
// Copy Link - adds "Copy Link" to Share submenu
{
load: () => import('./extensions/copy-links'),
factory: 'copyLinksExtensionFactory',
enabled: true,
},
];
22 changes: 22 additions & 0 deletions src/app/extensions/copy-links/copy-links-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FileModel } from '@osf/shared/models/files/file.model';
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

/**
* Factory function that creates Copy Link action extension.
*/
export function copyLinksExtensionFactory(): FileActionExtension[] {
return [
{
id: 'copy-link',
label: 'Copy Link',
icon: 'fas fa-link',
command: (ctx) => {
const file = ctx.target as FileModel;
navigator.clipboard.writeText(file.links.html);
},
parentId: 'share',
position: 'end',
visible: (ctx) => ctx.target.kind === 'file',
},
];
}
1 change: 1 addition & 0 deletions src/app/extensions/copy-links/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './copy-links-menu';
2 changes: 2 additions & 0 deletions src/app/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './copy-links';
export * from './onlyoffice';
29 changes: 29 additions & 0 deletions src/app/extensions/onlyoffice/create-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

import { OnlyOfficeConfig } from './edit-by-onlyoffice';

/**
* Factory function for Create File toolbar extension.
* Adds a "Create File" button that opens OnlyOffice to create a new document.
*
* NOTE: This is a dummy implementation for demonstration purposes.
* The actual OnlyOffice integration requires a running OnlyOffice Document Server.
*/
export function createFileExtensionFactory(config: OnlyOfficeConfig): FileActionExtension[] {
return [
{
id: 'create-file-onlyoffice',
label: 'Create File',
icon: 'fas fa-file-circle-plus',
command: (ctx) => {
const params = new URLSearchParams({
new: 'true',
path: ctx.target.path!,
});
window.open(`${config.editorUrl}?${params}`, '_blank');
},
position: 'end',
visible: (ctx) => ctx.location === 'file-list' && ctx.target.kind === 'folder' && !ctx.isViewOnly && ctx.canWrite,
},
];
}
30 changes: 30 additions & 0 deletions src/app/extensions/onlyoffice/edit-by-onlyoffice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';

export interface OnlyOfficeConfig {
editorUrl: string;
}

const SUPPORTED_EXTENSIONS = /\.(docx?|xlsx?|pptx?|odt|ods|odp)$/i;

/**
* Factory function for Edit by OnlyOffice action extension.
* Adds "Edit by OnlyOffice" option to file context menu.
*
* NOTE: This is a dummy implementation for demonstration purposes.
* The actual OnlyOffice integration requires a running OnlyOffice Document Server.
*/
export function editByOnlyOfficeExtensionFactory(config: OnlyOfficeConfig): FileActionExtension[] {
return [
{
id: 'edit-onlyoffice',
label: 'Edit by OnlyOffice',
icon: 'fas fa-edit',
command: (ctx) => {
const editorUrl = `${config.editorUrl}?fileId=${ctx.target.id}`;
window.open(editorUrl, '_blank');
},
position: 'start',
visible: (ctx) => ctx.target.kind === 'file' && ctx.canWrite && SUPPORTED_EXTENSIONS.test(ctx.target.name),
},
];
}
Loading