-import { Injectable } from '@angular/core'
-import { Router } from '@angular/router'
-import { ServerConfigPlugin } from '@shared/models'
+import { firstValueFrom, Observable, of } from 'rxjs'
+import { catchError, map, shareReplay } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
+import { VideoEditType } from '@app/+videos/+video-edit/shared/video-edit.type'
+import { AuthService } from '@app/core/auth'
+import { Notifier } from '@app/core/notification'
+import { MarkdownService } from '@app/core/renderer'
+import { RestExtractor } from '@app/core/rest'
import { ServerService } from '@app/core/server/server.service'
-import { ClientScript } from '@shared/models/plugins/plugin-package-json.model'
-import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
+import { getDevLocale, isOnDevLocale } from '@app/helpers'
+import { CustomModalComponent } from '@app/modal/custom-modal.component'
+import { PluginInfo, PluginsManager } from '@root-helpers/plugins-manager'
+import { getKeys } from '@shared/core-utils'
+import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n'
+import {
+ ClientHook,
+ ClientHookName,
+ PluginClientScope,
+ PluginTranslation,
+ PluginType,
+ PublicServerSetting,
+ RegisterClientFormFieldOptions,
+ RegisterClientRouteOptions,
+ RegisterClientSettingsScriptOptions,
+ RegisterClientVideoFieldOptions,
+ ServerConfigPlugin
+} from '@shared/models'
import { environment } from '../../../environments/environment'
-import { ReplaySubject } from 'rxjs'
-import { catchError, first, map, shareReplay } from 'rxjs/operators'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
-import { ClientHook, ClientHookName, clientHookObject } from '@shared/models/plugins/client-hook.model'
-import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
-import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
-import { HttpClient } from '@angular/common/http'
-import { RestExtractor } from '@app/shared/rest'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { PublicServerSetting } from '@shared/models/plugins/public-server.setting'
-
-interface HookStructValue extends RegisterClientHookOptions {
- plugin: ServerConfigPlugin
- clientScript: ClientScript
-}
-
-type PluginInfo = {
- plugin: ServerConfigPlugin
- clientScript: ClientScript
- pluginType: PluginType
- isTheme: boolean
+import { RegisterClientHelpers } from '../../../types/register-client-option.model'
+
+type FormFields = {
+ video: {
+ pluginInfo: PluginInfo
+ commonOptions: RegisterClientFormFieldOptions
+ videoFormOptions: RegisterClientVideoFieldOptions
+ }[]
}
@Injectable()
export class PluginService implements ClientHook {
- private static BASE_PLUGIN_URL = environment.apiUrl + '/api/v1/plugins'
+ private static BASE_PLUGIN_API_URL = environment.apiUrl + '/api/v1/plugins'
+ private static BASE_PLUGIN_URL = environment.apiUrl + '/plugins'
- pluginsBuilt = new ReplaySubject<boolean>(1)
+ translationsObservable: Observable<PluginTranslation>
- pluginsLoaded: { [ scope in PluginClientScope ]: ReplaySubject<boolean> } = {
- common: new ReplaySubject<boolean>(1),
- search: new ReplaySubject<boolean>(1),
- 'video-watch': new ReplaySubject<boolean>(1)
- }
+ customModal: CustomModalComponent
- private plugins: ServerConfigPlugin[] = []
- private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
- private loadedScripts: { [ script: string ]: boolean } = {}
- private loadedScopes: PluginClientScope[] = []
- private loadingScopes: { [id in PluginClientScope]?: boolean } = {}
+ private formFields: FormFields = {
+ video: []
+ }
+ private settingsScripts: { [ npmName: string ]: RegisterClientSettingsScriptOptions } = {}
+ private clientRoutes: { [ route: string ]: RegisterClientRouteOptions } = {}
- private hooks: { [ name: string ]: HookStructValue[] } = {}
+ private pluginsManager: PluginsManager
constructor (
- private router: Router,
+ private authService: AuthService,
+ private notifier: Notifier,
+ private markdownRenderer: MarkdownService,
private server: ServerService,
+ private zone: NgZone,
private authHttp: HttpClient,
- private restExtractor: RestExtractor
+ private restExtractor: RestExtractor,
+ @Inject(LOCALE_ID) private localeId: string
) {
+ this.loadTranslations()
+
+ this.pluginsManager = new PluginsManager({
+ peertubeHelpersFactory: this.buildPeerTubeHelpers.bind(this),
+ onFormFields: this.onFormFields.bind(this),
+ onSettingsScripts: this.onSettingsScripts.bind(this),
+ onClientRoute: this.onClientRoute.bind(this)
+ })
}
initializePlugins () {
- this.server.configLoaded
- .subscribe(() => {
- this.plugins = this.server.getConfig().plugin.registered
-
- this.buildScopeStruct()
+ this.pluginsManager.loadPluginsList(this.server.getHTMLConfig())
- this.pluginsBuilt.next(true)
- })
+ this.pluginsManager.ensurePluginsAreLoaded('common')
}
- ensurePluginsAreBuilt () {
- return this.pluginsBuilt.asObservable()
- .pipe(first(), shareReplay())
- .toPromise()
+ initializeCustomModal (customModal: CustomModalComponent) {
+ this.customModal = customModal
}
- ensurePluginsAreLoaded (scope: PluginClientScope) {
- this.loadPluginsByScope(scope)
-
- return this.pluginsLoaded[scope].asObservable()
- .pipe(first(), shareReplay())
- .toPromise()
+ runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
+ return this.zone.runOutsideAngular(() => {
+ return this.pluginsManager.runHook(hookName, result, params)
+ })
}
- addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
- const pathPrefix = this.getPluginPathPrefix(isTheme)
-
- for (const key of Object.keys(plugin.clientScripts)) {
- const clientScript = plugin.clientScripts[key]
-
- for (const scope of clientScript.scopes) {
- if (!this.scopes[scope]) this.scopes[scope] = []
-
- this.scopes[scope].push({
- plugin,
- clientScript: {
- script: environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
- scopes: clientScript.scopes
- },
- pluginType: isTheme ? PluginType.THEME : PluginType.PLUGIN,
- isTheme
- })
-
- this.loadedScripts[clientScript.script] = false
- }
- }
+ ensurePluginsAreLoaded (scope: PluginClientScope) {
+ return this.pluginsManager.ensurePluginsAreLoaded(scope)
}
- removePlugin (plugin: ServerConfigPlugin) {
- for (const key of Object.keys(this.scopes)) {
- this.scopes[key] = this.scopes[key].filter(o => o.plugin.name !== plugin.name)
- }
+ reloadLoadedScopes () {
+ return this.pluginsManager.reloadLoadedScopes()
}
- async reloadLoadedScopes () {
- for (const scope of this.loadedScopes) {
- await this.loadPluginsByScope(scope, true)
- }
+ getPluginsManager () {
+ return this.pluginsManager
}
- async loadPluginsByScope (scope: PluginClientScope, isReload = false) {
- if (this.loadingScopes[scope]) return
- if (!isReload && this.loadedScopes.includes(scope)) return
-
- this.loadingScopes[scope] = true
-
- try {
- await this.ensurePluginsAreBuilt()
-
- if (!isReload) this.loadedScopes.push(scope)
-
- const toLoad = this.scopes[ scope ]
- if (!Array.isArray(toLoad)) {
- this.loadingScopes[scope] = false
- this.pluginsLoaded[scope].next(true)
-
- return
- }
-
- const promises: Promise<any>[] = []
- for (const pluginInfo of toLoad) {
- const clientScript = pluginInfo.clientScript
-
- if (this.loadedScripts[ clientScript.script ]) continue
-
- promises.push(this.loadPlugin(pluginInfo))
-
- this.loadedScripts[ clientScript.script ] = true
- }
-
- await Promise.all(promises)
-
- this.pluginsLoaded[scope].next(true)
- this.loadingScopes[scope] = false
- } catch (err) {
- console.error('Cannot load plugins by scope %s.', scope, err)
- }
+ addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
+ return this.pluginsManager.addPlugin(plugin, isTheme)
}
- async runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
- if (!this.hooks[hookName]) return Promise.resolve(result)
-
- const hookType = getHookType(hookName)
-
- for (const hook of this.hooks[hookName]) {
- console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name)
-
- result = await internalRunHook(hook.handler, hookType, result, params, err => {
- console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err)
- })
- }
-
- return result
+ removePlugin (plugin: ServerConfigPlugin) {
+ return this.pluginsManager.removePlugin(plugin)
}
nameToNpmName (name: string, type: PluginType) {
return prefix + name
}
- pluginTypeFromNpmName (npmName: string) {
- return npmName.startsWith('peertube-plugin-')
- ? PluginType.PLUGIN
- : PluginType.THEME
+ getRegisteredVideoFormFields (type: VideoEditType) {
+ return this.formFields.video.filter(f => f.videoFormOptions.type === type)
+ }
+
+ getRegisteredSettingsScript (npmName: string) {
+ return this.settingsScripts[npmName]
}
- private loadPlugin (pluginInfo: PluginInfo) {
- const { plugin, clientScript } = pluginInfo
+ getRegisteredClientRoute (route: string) {
+ return this.clientRoutes[route]
+ }
- const registerHook = (options: RegisterClientHookOptions) => {
- if (clientHookObject[options.target] !== true) {
- console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name)
- return
- }
+ getAllRegisteredClientRoutes () {
+ return Object.keys(this.clientRoutes)
+ }
+
+ async translateSetting (npmName: string, setting: RegisterClientFormFieldOptions) {
+ for (const key of getKeys(setting, [ 'label', 'html', 'descriptionHTML' ])) {
+ if (setting[key]) setting[key] = await this.translateBy(npmName, setting[key])
+ }
- if (!this.hooks[options.target]) this.hooks[options.target] = []
+ if (Array.isArray(setting.options)) {
+ const newOptions = []
- this.hooks[options.target].push({
- plugin,
- clientScript,
- target: options.target,
- handler: options.handler,
- priority: options.priority || 0
- })
+ for (const o of setting.options) {
+ newOptions.push({
+ value: o.value,
+ label: await this.translateBy(npmName, o.label)
+ })
+ }
+
+ setting.options = newOptions
}
+ }
- const peertubeHelpers = this.buildPeerTubeHelpers(pluginInfo)
+ translateBy (npmName: string, toTranslate: string) {
+ const obs = this.translationsObservable
+ .pipe(
+ map(allTranslations => allTranslations[npmName]),
+ map(translations => peertubeTranslate(toTranslate, translations))
+ )
- console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
+ return firstValueFrom(obs)
+ }
- return import(/* webpackIgnore: true */ clientScript.script)
- .then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers }))
- .then(() => this.sortHooksByPriority())
- .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
+ private onFormFields (
+ pluginInfo: PluginInfo,
+ commonOptions: RegisterClientFormFieldOptions,
+ videoFormOptions: RegisterClientVideoFieldOptions
+ ) {
+ this.formFields.video.push({
+ pluginInfo,
+ commonOptions,
+ videoFormOptions
+ })
}
- private buildScopeStruct () {
- for (const plugin of this.plugins) {
- this.addPlugin(plugin)
- }
+ private onSettingsScripts (pluginInfo: PluginInfo, options: RegisterClientSettingsScriptOptions) {
+ this.settingsScripts[pluginInfo.plugin.npmName] = options
}
- private sortHooksByPriority () {
- for (const hookName of Object.keys(this.hooks)) {
- this.hooks[hookName].sort((a, b) => {
- return b.priority - a.priority
- })
- }
+ private onClientRoute (options: RegisterClientRouteOptions) {
+ const route = options.route.startsWith('/')
+ ? options.route
+ : `/${options.route}`
+
+ this.clientRoutes[route] = options
}
- private buildPeerTubeHelpers (pluginInfo: PluginInfo) {
+ private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers {
const { plugin } = pluginInfo
+ const npmName = pluginInfo.plugin.npmName
return {
getBaseStaticRoute: () => {
- const pathPrefix = this.getPluginPathPrefix(pluginInfo.isTheme)
+ const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/static`
},
+ getBaseRouterRoute: () => {
+ const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
+ return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/router`
+ },
+
+ getBaseWebSocketRoute: () => {
+ const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
+ return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/ws`
+ },
+
+ getBasePluginClientPath: () => {
+ return '/p'
+ },
+
getSettings: () => {
- const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType)
- const path = PluginService.BASE_PLUGIN_URL + '/' + npmName + '/public-settings'
+ const path = PluginService.BASE_PLUGIN_API_URL + '/' + npmName + '/public-settings'
- return this.authHttp.get<PublicServerSetting>(path)
+ const obs = this.authHttp.get<PublicServerSetting>(path)
.pipe(
map(p => p.publicSettings),
catchError(res => this.restExtractor.handleError(res))
)
- .toPromise()
+
+ return firstValueFrom(obs)
+ },
+
+ getServerConfig: () => {
+ const obs = this.server.getConfig()
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+
+ return firstValueFrom(obs)
+ },
+
+ isLoggedIn: () => {
+ return this.authService.isLoggedIn()
+ },
+
+ getAuthHeader: () => {
+ if (!this.authService.isLoggedIn()) return undefined
+
+ const value = this.authService.getRequestHeaderValue()
+ return { Authorization: value }
+ },
+
+ notifier: {
+ info: (text: string, title?: string, timeout?: number) => this.zone.run(() => this.notifier.info(text, title, timeout)),
+ error: (text: string, title?: string, timeout?: number) => this.zone.run(() => this.notifier.error(text, title, timeout)),
+ success: (text: string, title?: string, timeout?: number) => this.zone.run(() => this.notifier.success(text, title, timeout))
+ },
+
+ showModal: (input: {
+ title: string
+ content: string
+ close?: boolean
+ cancel?: { value: string, action?: () => void }
+ confirm?: { value: string, action?: () => void }
+ }) => {
+ this.zone.run(() => this.customModal.show(input))
+ },
+
+ markdownRenderer: {
+ textMarkdownToHTML: (textMarkdown: string) => {
+ return this.markdownRenderer.textMarkdownToHTML({ markdown: textMarkdown })
+ },
+
+ enhancedMarkdownToHTML: (enhancedMarkdown: string) => {
+ return this.markdownRenderer.enhancedMarkdownToHTML({ markdown: enhancedMarkdown })
+ }
+ },
+
+ translate: (value: string) => {
+ return this.translateBy(npmName, value)
}
}
}
- private getPluginPathPrefix (isTheme: boolean) {
- return isTheme ? '/themes' : '/plugins'
+ private loadTranslations () {
+ const completeLocale = isOnDevLocale() ? getDevLocale() : getCompleteLocale(this.localeId)
+
+ // Default locale, nothing to translate
+ if (isDefaultLocale(completeLocale)) this.translationsObservable = of({}).pipe(shareReplay())
+
+ this.translationsObservable = this.authHttp
+ .get<PluginTranslation>(PluginService.BASE_PLUGIN_URL + '/translations/' + completeLocale + '.json')
+ .pipe(shareReplay())
}
}