mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
Accept config schema for uploaded plugin (#7414)
* fix(plugin-config): Adds two-way binding of plugin config dialog * feature(plugin-config): Adds config-schema support for uploaded plugins
This commit is contained in:
parent
6c1a0b54f9
commit
79c6d903a6
5 changed files with 170 additions and 5 deletions
|
|
@ -8,6 +8,7 @@ export interface CachedPlugin {
|
|||
indexHtml?: string;
|
||||
icon?: string;
|
||||
translations?: Record<string, string>; // language code -> JSON string
|
||||
configSchema?: string;
|
||||
uploadDate: number;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ export class PluginCacheService {
|
|||
indexHtml?: string,
|
||||
icon?: string,
|
||||
translations?: Record<string, string>,
|
||||
configSchema?: string,
|
||||
): Promise<void> {
|
||||
// Log plugin data sizes for debugging
|
||||
const translationsSize = translations
|
||||
|
|
@ -105,6 +107,7 @@ export class PluginCacheService {
|
|||
indexHtml,
|
||||
icon,
|
||||
translations,
|
||||
configSchema,
|
||||
uploadDate: Date.now(),
|
||||
};
|
||||
|
||||
|
|
|
|||
139
src/app/plugins/plugin-config.service.spec.ts
Normal file
139
src/app/plugins/plugin-config.service.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { PluginConfigService } from './plugin-config.service';
|
||||
import { PluginCacheService, CachedPlugin } from './plugin-cache.service';
|
||||
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
|
||||
import { PluginManifest } from './plugin-api.model';
|
||||
|
||||
const BASE_MANIFEST: PluginManifest = {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
manifestVersion: 1,
|
||||
minSupVersion: '1.0.0',
|
||||
hooks: [],
|
||||
permissions: [],
|
||||
jsonSchemaCfg: 'config-schema.json',
|
||||
};
|
||||
|
||||
const VALID_SCHEMA = { type: 'object', properties: { apiKey: { type: 'string' } } };
|
||||
|
||||
const makeCache = (overrides: Partial<CachedPlugin> = {}): CachedPlugin => ({
|
||||
id: 'test-plugin',
|
||||
manifest: JSON.stringify(BASE_MANIFEST),
|
||||
code: 'console.log("test");',
|
||||
uploadDate: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PluginConfigService', () => {
|
||||
let service: PluginConfigService;
|
||||
let cacheSpy: jasmine.SpyObj<PluginCacheService>;
|
||||
let httpSpy: jasmine.SpyObj<HttpClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
cacheSpy = jasmine.createSpyObj('PluginCacheService', ['getPlugin']);
|
||||
httpSpy = jasmine.createSpyObj('HttpClient', ['get']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PluginConfigService,
|
||||
{ provide: PluginCacheService, useValue: cacheSpy },
|
||||
{ provide: HttpClient, useValue: httpSpy },
|
||||
{
|
||||
provide: PluginUserPersistenceService,
|
||||
useValue: jasmine.createSpyObj('PluginUserPersistenceService', [
|
||||
'loadPluginUserData',
|
||||
'persistPluginUserData',
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PluginConfigService);
|
||||
});
|
||||
|
||||
describe('loadPluginConfigSchema - uploaded:// plugins', () => {
|
||||
it('should return parsed schema from cache for uploaded plugin', async () => {
|
||||
cacheSpy.getPlugin.and.resolveTo(
|
||||
makeCache({ configSchema: JSON.stringify(VALID_SCHEMA) }),
|
||||
);
|
||||
|
||||
const result = await service.loadPluginConfigSchema(
|
||||
BASE_MANIFEST,
|
||||
'uploaded://test-plugin',
|
||||
);
|
||||
|
||||
expect(cacheSpy.getPlugin).toHaveBeenCalledWith('test-plugin');
|
||||
expect(result).toEqual(VALID_SCHEMA as any);
|
||||
});
|
||||
|
||||
it('should throw when configSchema is missing from cache', async () => {
|
||||
cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: undefined }));
|
||||
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
|
||||
).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/);
|
||||
});
|
||||
|
||||
it('should throw when plugin is not in cache at all', async () => {
|
||||
cacheSpy.getPlugin.and.resolveTo(null);
|
||||
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
|
||||
).toBeRejectedWithError(/No config schema found for uploaded plugin test-plugin/);
|
||||
});
|
||||
|
||||
it('should throw when configSchema contains invalid JSON', async () => {
|
||||
cacheSpy.getPlugin.and.resolveTo(makeCache({ configSchema: '{ not valid json' }));
|
||||
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(BASE_MANIFEST, 'uploaded://test-plugin'),
|
||||
).toBeRejected();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPluginConfigSchema - bundled/remote plugins', () => {
|
||||
it('should fetch schema via HTTP for non-uploaded plugins', async () => {
|
||||
httpSpy.get.and.returnValue(of(VALID_SCHEMA) as any);
|
||||
|
||||
const result = await service.loadPluginConfigSchema(
|
||||
BASE_MANIFEST,
|
||||
'assets/bundled-plugins/test-plugin',
|
||||
);
|
||||
|
||||
expect(httpSpy.get).toHaveBeenCalledWith(
|
||||
'assets/bundled-plugins/test-plugin/config-schema.json',
|
||||
);
|
||||
expect(result).toEqual(VALID_SCHEMA as any);
|
||||
});
|
||||
|
||||
it('should throw when HTTP fetch fails', async () => {
|
||||
httpSpy.get.and.returnValue(throwError(() => new Error('404 Not Found')) as any);
|
||||
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(
|
||||
BASE_MANIFEST,
|
||||
'assets/bundled-plugins/test-plugin',
|
||||
),
|
||||
).toBeRejected();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPluginConfigSchema - guard conditions', () => {
|
||||
it('should throw when manifest has no jsonSchemaCfg', async () => {
|
||||
const manifest: PluginManifest = { ...BASE_MANIFEST, jsonSchemaCfg: undefined };
|
||||
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(manifest, 'uploaded://test-plugin'),
|
||||
).toBeRejectedWithError('Plugin does not have a JSON schema configuration');
|
||||
});
|
||||
|
||||
it('should throw when pluginPath is empty', async () => {
|
||||
await expectAsync(
|
||||
service.loadPluginConfigSchema(BASE_MANIFEST, ''),
|
||||
).toBeRejectedWithError(/Plugin path not provided/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
|||
import { JSONSchema7 } from 'json-schema';
|
||||
import { PluginManifest } from './plugin-api.model';
|
||||
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
|
||||
import { PluginCacheService } from './plugin-cache.service';
|
||||
import { PluginLog } from '../core/log';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ interface PluginConfigData {
|
|||
export class PluginConfigService {
|
||||
private readonly _http = inject(HttpClient);
|
||||
private readonly _pluginUserPersistenceService = inject(PluginUserPersistenceService);
|
||||
private readonly _pluginCacheService = inject(PluginCacheService);
|
||||
|
||||
/**
|
||||
* Load the JSON schema configuration file for a plugin
|
||||
|
|
@ -36,11 +38,16 @@ export class PluginConfigService {
|
|||
// Build the URL to the config schema file
|
||||
let schemaUrl: string;
|
||||
if (pluginPath.startsWith('uploaded://')) {
|
||||
// For uploaded plugins, we need to fetch from the cached files
|
||||
// This would require extending the cache service, but for now we'll throw an error
|
||||
throw new Error(
|
||||
'Loading config schema for uploaded plugins is not yet implemented',
|
||||
);
|
||||
const pluginId = pluginPath.replace('uploaded://', '');
|
||||
const cached = await this._pluginCacheService.getPlugin(pluginId);
|
||||
if (!cached?.configSchema) {
|
||||
throw new Error(`No config schema found for uploaded plugin ${manifest.id}`);
|
||||
}
|
||||
const schema = JSON.parse(cached.configSchema) as JSONSchema7;
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
throw new Error('Invalid JSON schema format');
|
||||
}
|
||||
return schema;
|
||||
} else {
|
||||
// For regular plugins, construct the URL
|
||||
schemaUrl = `${pluginPath}/${manifest.jsonSchemaCfg}`;
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,19 @@ export class PluginService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract config schema if specified in manifest
|
||||
let configSchema: string | undefined;
|
||||
if (manifest.jsonSchemaCfg && extractedFiles[manifest.jsonSchemaCfg]) {
|
||||
const schemaBytes = extractedFiles[manifest.jsonSchemaCfg];
|
||||
if (schemaBytes.length <= MAX_PLUGIN_MANIFEST_SIZE) {
|
||||
configSchema = new TextDecoder().decode(schemaBytes);
|
||||
} else {
|
||||
PluginLog.err(
|
||||
`Plugin config schema ${manifest.jsonSchemaCfg} is too large, skipping`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze plugin code (informational only - KISS approach)
|
||||
const codeAnalysis = this._pluginSecurity.analyzePluginCode(pluginCode, manifest);
|
||||
if (codeAnalysis.warnings.length > 0) {
|
||||
|
|
@ -1106,6 +1119,8 @@ export class PluginService implements OnDestroy {
|
|||
pluginCode,
|
||||
indexHtml || undefined,
|
||||
iconContent || undefined,
|
||||
undefined,
|
||||
configSchema,
|
||||
);
|
||||
|
||||
// Store index.html content if it exists
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ interface PluginConfigData {
|
|||
[form]="form"
|
||||
[fields]="fields"
|
||||
[model]="model"
|
||||
(modelChange)="model = $event"
|
||||
></formly-form>
|
||||
</form>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue