Skip to content

Commit ceb269e

Browse files
committed
feat(file-menu): add plugin architecture for file actions
- Add FileActionExtension for menu/toolbar/both targets - Add ExtensionRegistry service for dynamic extension loading - Add extensions.config.ts for configuration - Add Copy Link as example internal extension - Support external npm packages as extensions See docs/file-extensions.md for usage.
1 parent 1f17062 commit ceb269e

25 files changed

+1125
-24
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ testem.log
4444
# System files
4545
.DS_Store
4646
Thumbs.db
47+
48+
# Generated extensions config
49+
src/app/extensions.config.ts

docs/file-extensions.md

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# File Extensions
2+
3+
Plugin architecture for adding custom actions to the file browser.
4+
5+
## Overview
6+
7+
Extensions can be placed internally (`src/app/extensions/`) or externally (npm package). Both are managed via `extensions.config.ts`.
8+
9+
| Location | Example |
10+
|----------|---------|
11+
| `src/app/extensions/` | Copy Links |
12+
| npm package | [osf-extension-onlyoffice](https://github.com/yacchin1205/osf-extension-onlyoffice) (experimental) |
13+
14+
> **Note:** `osf-extension-onlyoffice` is currently experimental and used for testing the plugin architecture.
15+
16+
## FileActionExtension
17+
18+
```typescript
19+
interface FileActionExtension {
20+
id: string;
21+
label: string;
22+
icon: string;
23+
command: (ctx: FileActionContext) => void;
24+
parentId?: string;
25+
position?: 'start' | 'end' | number;
26+
visible?: (ctx: FileActionContext) => boolean;
27+
disabled?: (ctx: FileActionContext) => boolean;
28+
}
29+
```
30+
31+
### parentId
32+
33+
If `parentId` is set, the extension appears only in that submenu (e.g., `'share'`).
34+
If not set, the extension appears in both menu and toolbar.
35+
36+
### FileActionContext
37+
38+
```typescript
39+
interface FileActionContext {
40+
target: FileModel | FileDetailsModel | FileFolderModel;
41+
location: 'file-list' | 'file-detail';
42+
isViewOnly: boolean;
43+
canWrite: boolean;
44+
}
45+
```
46+
47+
## Example: Submenu Extension
48+
49+
```typescript
50+
export function copyLinksExtensionFactory(): FileActionExtension[] {
51+
return [
52+
{
53+
id: 'copy-link',
54+
label: 'Copy Link',
55+
icon: 'fas fa-link',
56+
command: (ctx) => {
57+
navigator.clipboard.writeText(ctx.target.links.html);
58+
},
59+
parentId: 'share', // appears in Share submenu only
60+
position: 'end',
61+
visible: (ctx) => ctx.target.kind === 'file',
62+
disabled: (ctx) => ctx.isViewOnly,
63+
},
64+
];
65+
}
66+
```
67+
68+
> **Heads up:** 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.
69+
70+
## Example: Menu + Toolbar Extension
71+
72+
```typescript
73+
export function createFileExtensionFactory(config: Config): FileActionExtension[] {
74+
return [
75+
{
76+
id: 'create-file',
77+
label: 'Create File',
78+
icon: 'fas fa-file-plus',
79+
command: (ctx) => {
80+
window.open(`${config.editorUrl}?path=${ctx.target.path}`, '_blank');
81+
},
82+
// no parentId: appears in both menu and toolbar
83+
position: 'end',
84+
visible: (ctx) =>
85+
ctx.location === 'file-list' &&
86+
ctx.target.kind === 'folder' &&
87+
!ctx.isViewOnly &&
88+
ctx.canWrite,
89+
},
90+
];
91+
}
92+
```
93+
94+
## Configuration
95+
96+
The extension configuration is managed via `extensions.config.ts`, which is generated at build time.
97+
98+
### File Structure
99+
100+
```
101+
src/app/
102+
extensions.config.ts # Generated (not in git)
103+
extensions.config.default.ts # Default config (in git)
104+
```
105+
106+
### Build-time Configuration
107+
108+
By default, `extensions.config.default.ts` is used. To use a custom configuration:
109+
110+
```bash
111+
EXTENSIONS_CONFIG=/path/to/my-extensions.config.ts npm run build
112+
```
113+
114+
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:
115+
- If `EXTENSIONS_CONFIG` is set, copies that file
116+
- Otherwise, copies `extensions.config.default.ts`
117+
118+
## Registration
119+
120+
### extensions.config.ts
121+
122+
```typescript
123+
export const extensionConfig: ExtensionConfig[] = [
124+
// Internal
125+
{
126+
load: () => import('./extensions/copy-links'),
127+
factory: 'copyLinksExtensionFactory',
128+
enabled: true,
129+
},
130+
// External
131+
{
132+
load: () => import('@nii/osf-extension-onlyoffice'),
133+
factory: 'editWithOnlyOfficeExtensionFactory',
134+
enabled: true,
135+
config: { editorUrl: 'https://...' },
136+
},
137+
{
138+
load: () => import('@nii/osf-extension-onlyoffice'),
139+
factory: 'createFileExtensionFactory',
140+
enabled: true,
141+
config: { editorUrl: 'https://...' },
142+
},
143+
];
144+
```
145+
146+
## Creating an External Plugin
147+
148+
```
149+
my-extension/
150+
├── package.json
151+
├── tsconfig.json
152+
└── src/
153+
├── index.ts # re-export
154+
├── types.ts # type definitions
155+
└── my-feature.ts # factory function
156+
```
157+
158+
### types.ts
159+
160+
```typescript
161+
export interface FileActionContext {
162+
target: { id: string; name: string; kind: string; path?: string; links: { html: string } };
163+
location: 'file-list' | 'file-detail';
164+
isViewOnly: boolean;
165+
canWrite: boolean;
166+
}
167+
168+
export interface FileActionExtension {
169+
id: string;
170+
label: string;
171+
icon: string;
172+
command: (ctx: FileActionContext) => void;
173+
parentId?: string;
174+
position?: 'start' | 'end' | number;
175+
visible?: (ctx: FileActionContext) => boolean;
176+
disabled?: (ctx: FileActionContext) => boolean;
177+
}
178+
```
179+
180+
The `config` parameter in factory functions is passed from `extensions.config.ts`.
181+
182+
## Developing External Plugins with npm link
183+
184+
To test an external plugin locally without publishing to npm:
185+
186+
```bash
187+
# 1. In the extension directory, build and create a global link
188+
cd ../osf-extension-onlyoffice
189+
npm run build
190+
npm link
191+
192+
# 2. In angular-osf, link the package
193+
cd ../angular-osf
194+
npm link @nii/osf-extension-onlyoffice
195+
196+
# 3. Add to extensions.config.ts and start
197+
npm start
198+
```
199+
200+
After making changes to the extension:
201+
202+
```bash
203+
# Rebuild the extension
204+
cd ../osf-extension-onlyoffice
205+
npm run build
206+
207+
# Restart angular-osf to pick up changes
208+
```
209+
210+
To unlink:
211+
212+
```bash
213+
cd ../angular-osf
214+
npm unlink @nii/osf-extension-onlyoffice
215+
```

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"scripts": {
55
"ng": "ng",
66
"analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks",
7+
"prebuild": "node scripts/setup-extensions.js",
8+
"prestart": "node scripts/setup-extensions.js",
79
"build": "ng build",
810
"check:config": "node ./docker/check-config.js",
911
"ci:test": "jest",
@@ -23,6 +25,7 @@
2325
"start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker",
2426
"start:test": "ng serve --configuration test-osf",
2527
"start:test:future": "ng serve --configuration test",
28+
"pretest": "node scripts/setup-extensions.js",
2629
"test": "jest",
2730
"test:watch": "jest --watch",
2831
"test:coverage": "jest --coverage && npm run test:display",

scripts/setup-extensions.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const targetPath = path.join(__dirname, '../src/app/extensions.config.ts');
5+
const defaultPath = path.join(__dirname, '../src/app/extensions.config.default.ts');
6+
const customPath = process.env.EXTENSIONS_CONFIG;
7+
8+
if (customPath) {
9+
const resolvedPath = path.resolve(customPath);
10+
if (!fs.existsSync(resolvedPath)) {
11+
throw new Error(`EXTENSIONS_CONFIG file not found: ${resolvedPath}`);
12+
}
13+
fs.copyFileSync(resolvedPath, targetPath);
14+
console.log(`Extensions config: ${resolvedPath}`);
15+
} else if (!fs.existsSync(targetPath)) {
16+
fs.copyFileSync(defaultPath, targetPath);
17+
console.log(`Extensions config: ${defaultPath} (default)`);
18+
}

src/app/app.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { authInterceptor } from '@core/interceptors/auth.interceptor';
1717
import { errorInterceptor } from '@core/interceptors/error.interceptor';
1818
import { viewOnlyInterceptor } from '@core/interceptors/view-only.interceptor';
1919
import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/provider/application.initialization.provider';
20+
import { EXTENSION_INITIALIZATION_PROVIDER } from '@core/provider/extension.initialization.provider';
2021
import { SENTRY_PROVIDER } from '@core/provider/sentry.provider';
2122

2223
import CustomPreset from './core/theme/custom-preset';
@@ -53,5 +54,8 @@ export const appConfig: ApplicationConfig = {
5354
provideStore(STATES),
5455
provideZoneChangeDetection({ eventCoalescing: true }),
5556
SENTRY_PROVIDER,
57+
58+
// Dynamic Extension Loading (configured in extensions.config.ts)
59+
EXTENSION_INITIALIZATION_PROVIDER,
5660
],
5761
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { APP_INITIALIZER, Provider } from '@angular/core';
2+
3+
import { ExtensionRegistry } from '@core/services/extension-registry.service';
4+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
5+
6+
import { ExtensionConfig, extensionConfig } from '../../extensions.config';
7+
8+
type FactoryFunction = (config?: unknown) => FileActionExtension[];
9+
10+
async function loadExtensions(registry: ExtensionRegistry): Promise<void> {
11+
for (const ext of extensionConfig) {
12+
if (!ext.enabled) continue;
13+
await loadExtension(ext, registry);
14+
}
15+
}
16+
17+
async function loadExtension(extConfig: ExtensionConfig, registry: ExtensionRegistry): Promise<void> {
18+
const module = await extConfig.load();
19+
const factory = (module as Record<string, FactoryFunction>)[extConfig.factory];
20+
21+
if (typeof factory !== 'function') {
22+
throw new Error(`Extension factory "${extConfig.factory}" not found in module`);
23+
}
24+
25+
const extensions = factory(extConfig.config);
26+
registry.register(extensions);
27+
}
28+
29+
export const EXTENSION_INITIALIZATION_PROVIDER: Provider = {
30+
provide: APP_INITIALIZER,
31+
useFactory: (registry: ExtensionRegistry) => {
32+
return () => loadExtensions(registry);
33+
},
34+
deps: [ExtensionRegistry],
35+
multi: true,
36+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
4+
5+
import { ExtensionRegistry } from './extension-registry.service';
6+
7+
describe('ExtensionRegistry', () => {
8+
let service: ExtensionRegistry;
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({});
12+
service = TestBed.inject(ExtensionRegistry);
13+
});
14+
15+
it('registers extensions cumulatively', () => {
16+
const extA: FileActionExtension = {
17+
id: 'a',
18+
label: 'A',
19+
icon: 'a',
20+
command: jest.fn(),
21+
};
22+
const extB: FileActionExtension = {
23+
id: 'b',
24+
label: 'B',
25+
icon: 'b',
26+
command: jest.fn(),
27+
};
28+
29+
service.register([extA]);
30+
service.register([extB]);
31+
32+
expect(service.extensions()).toEqual([extA, extB]);
33+
});
34+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Injectable, signal } from '@angular/core';
2+
3+
import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token';
4+
5+
/**
6+
* Central registry for all plugin extensions.
7+
* Extensions are registered at app initialization time via APP_INITIALIZER.
8+
*/
9+
@Injectable({ providedIn: 'root' })
10+
export class ExtensionRegistry {
11+
private readonly _extensions = signal<FileActionExtension[]>([]);
12+
13+
/** Read-only signal of all extensions */
14+
readonly extensions = this._extensions.asReadonly();
15+
16+
register(extensions: FileActionExtension[]): void {
17+
this._extensions.update((current) => [...current, ...extensions]);
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Extension Configuration
3+
*
4+
* See docs/file-extensions.md for details.
5+
*/
6+
export interface ExtensionConfig {
7+
load: () => Promise<unknown>;
8+
factory: string;
9+
enabled: boolean;
10+
config?: unknown;
11+
}
12+
13+
export const extensionConfig: ExtensionConfig[] = [
14+
// Copy Link - adds "Copy Link" to Share submenu
15+
{
16+
load: () => import('./extensions/copy-links'),
17+
factory: 'copyLinksExtensionFactory',
18+
enabled: true,
19+
},
20+
];

0 commit comments

Comments
 (0)