-import { catchError } from 'rxjs/operators'
+import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { environment } from '../../../../environments/environment'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { PluginType } from '@shared/models/plugins/plugin.type'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { ResultList } from '@shared/models'
+import { peertubeTranslate, ResultList } from '@shared/models'
import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model'
import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model'
import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
import { RegisteredServerSettings, RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
import { PluginService } from '@app/core/plugins/plugin.service'
+import { Observable } from 'rxjs'
@Injectable()
export class PluginApiService {
const path = PluginApiService.BASE_PLUGIN_URL + '/' + npmName + '/registered-settings'
return this.authHttp.get<RegisteredServerSettings>(path)
- .pipe(catchError(res => this.restExtractor.handleError(res)))
+ .pipe(
+ switchMap(res => this.translateSettingsLabel(npmName, res)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
}
updatePluginSettings (pluginName: string, pluginType: PluginType, settings: any) {
return this.authHttp.post(PluginApiService.BASE_PLUGIN_URL + '/install', body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
+
+ private translateSettingsLabel (npmName: string, res: RegisteredServerSettings): Observable<RegisteredServerSettings> {
+ return this.pluginService.translationsObservable
+ .pipe(
+ map(allTranslations => allTranslations[npmName]),
+ map(translations => {
+ const registeredSettings = res.registeredSettings
+ .map(r => {
+ return Object.assign({}, r, { label: peertubeTranslate(r.label, translations) })
+ })
+
+ return { registeredSettings }
+ })
+ )
+ }
}
-import { Injectable, NgZone } from '@angular/core'
+import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
import { Router } from '@angular/router'
-import { ServerConfigPlugin } from '@shared/models'
+import { getCompleteLocale, isDefaultLocale, peertubeTranslate, ServerConfigPlugin } from '@shared/models'
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 { environment } from '../../../environments/environment'
-import { ReplaySubject } from 'rxjs'
+import { Observable, of, 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 { RestExtractor } from '@app/shared/rest'
import { PluginType } from '@shared/models/plugins/plugin.type'
import { PublicServerSetting } from '@shared/models/plugins/public-server.setting'
+import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
+import { RegisterClientHelpers } from '../../../types/register-client-option.model'
+import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model'
interface HookStructValue extends RegisterClientHookOptions {
plugin: ServerConfigPlugin
@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)
'video-watch': new ReplaySubject<boolean>(1)
}
+ translationsObservable: Observable<PluginTranslation>
+
private plugins: ServerConfigPlugin[] = []
private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
private loadedScripts: { [ script: string ]: boolean } = {}
private server: ServerService,
private zone: NgZone,
private authHttp: HttpClient,
- private restExtractor: RestExtractor
+ private restExtractor: RestExtractor,
+ @Inject(LOCALE_ID) private localeId: string
) {
+ this.loadTranslations()
}
initializePlugins () {
}
}
- private buildPeerTubeHelpers (pluginInfo: PluginInfo) {
+ private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers {
const { plugin } = pluginInfo
+ const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType)
return {
getBaseStaticRoute: () => {
},
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)
.pipe(
catchError(res => this.restExtractor.handleError(res))
)
.toPromise()
+ },
+
+ translate: (value: string) => {
+ return this.translationsObservable
+ .pipe(map(allTranslations => allTranslations[npmName]))
+ .pipe(map(translations => peertubeTranslate(value, translations)))
+ .toPromise()
}
}
}
+ 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())
+ }
+
private getPluginPathPrefix (isTheme: boolean) {
return isTheme ? '/themes' : '/plugins'
}
import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
export interface VideosProvider {
export type RegisterClientOptions = {
registerHook: (options: RegisterClientHookOptions) => void
- peertubeHelpers: {
- getBaseStaticRoute: () => string
+ peertubeHelpers: RegisterClientHelpers
+}
+
+export type RegisterClientHelpers = {
+ getBaseStaticRoute: () => string
+
+ getSettings: () => Promise<{ [ name: string ]: string }>
- getSettings: () => Promise<{ [ name: string ]: string }>
- }
+ translate: (toTranslate: string) => Promise<string>
}
import * as express from 'express'
import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
import { join } from 'path'
-import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
+import { PluginManager, RegisteredPlugin } from '../lib/plugins/plugin-manager'
import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
import { serveThemeCSSValidator } from '../middlewares/validators/themes'
import { PluginType } from '../../shared/models/plugins/plugin.type'
import { isTestInstance } from '../helpers/core-utils'
+import { getCompleteLocale, is18nLocale } from '../../shared/models/i18n'
const sendFileOptions = {
maxAge: '30 days',
servePluginGlobalCSS
)
+pluginsRouter.get('/plugins/translations/:locale.json',
+ getPluginTranslations
+)
+
pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
servePluginStaticDirectoryValidator(PluginType.PLUGIN),
servePluginStaticDirectory
return res.sendFile(PLUGIN_GLOBAL_CSS_PATH, globalCSSOptions)
}
+function getPluginTranslations (req: express.Request, res: express.Response) {
+ const locale = req.params.locale
+
+ if (is18nLocale(locale)) {
+ const completeLocale = getCompleteLocale(locale)
+ const json = PluginManager.Instance.getTranslations(completeLocale)
+
+ return res.json(json)
+ }
+
+ return res.sendStatus(404)
+}
+
function servePluginStaticDirectory (req: express.Request, res: express.Response) {
const plugin: RegisteredPlugin = res.locals.registeredPlugin
const staticEndpoint = req.params.staticEndpoint
return isUrlValid(value)
}
-function isStaticDirectoriesValid (staticDirs: any) {
+function areStaticDirectoriesValid (staticDirs: any) {
if (!exists(staticDirs) || typeof staticDirs !== 'object') return false
for (const key of Object.keys(staticDirs)) {
return true
}
-function isClientScriptsValid (clientScripts: any[]) {
+function areClientScriptsValid (clientScripts: any[]) {
return isArray(clientScripts) &&
clientScripts.every(c => {
return isSafePath(c.script) && isArray(c.scopes)
})
}
-function isCSSPathsValid (css: any[]) {
+function areTranslationPathsValid (translations: any) {
+ if (!exists(translations) || typeof translations !== 'object') return false
+
+ for (const key of Object.keys(translations)) {
+ if (!isSafePath(translations[key])) return false
+ }
+
+ return true
+}
+
+function areCSSPathsValid (css: any[]) {
return isArray(css) && css.every(c => isSafePath(c))
}
exists(packageJSON.author) &&
isUrlValid(packageJSON.bugs) &&
(pluginType === PluginType.THEME || isSafePath(packageJSON.library)) &&
- isStaticDirectoriesValid(packageJSON.staticDirs) &&
- isCSSPathsValid(packageJSON.css) &&
- isClientScriptsValid(packageJSON.clientScripts)
+ areStaticDirectoriesValid(packageJSON.staticDirs) &&
+ areCSSPathsValid(packageJSON.css) &&
+ areClientScriptsValid(packageJSON.clientScripts) &&
+ areTranslationPathsValid(packageJSON.translations)
}
function isLibraryCodeValid (library: any) {
import { basename, join } from 'path'
import { CONFIG } from '../../initializers/config'
import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
-import { ClientScript, PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
+import {
+ ClientScript,
+ PluginPackageJson,
+ PluginTranslationPaths as PackagePluginTranslations
+} from '../../../shared/models/plugins/plugin-package-json.model'
import { createReadStream, createWriteStream } from 'fs'
import { PLUGIN_GLOBAL_CSS_PATH, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { PluginVideoLanguageManager } from '../../../shared/models/plugins/plugin-video-language-manager.model'
import { PluginVideoCategoryManager } from '../../../shared/models/plugins/plugin-video-category-manager.model'
import { PluginVideoLicenceManager } from '../../../shared/models/plugins/plugin-video-licence-manager.model'
+import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
export interface RegisteredPlugin {
npmName: string
}
}
+type PluginLocalesTranslations = {
+ [ locale: string ]: PluginTranslation
+}
+
export class PluginManager implements ServerHook {
private static instance: PluginManager
private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {}
private settings: { [ name: string ]: RegisterServerSettingOptions[] } = {}
private hooks: { [ name: string ]: HookInformationValue[] } = {}
+ private translations: PluginLocalesTranslations = {}
private updatedVideoConstants: UpdatedVideoConstant = {
language: {},
return this.settings[npmName] || []
}
+ getTranslations (locale: string) {
+ return this.translations[locale] || {}
+ }
+
// ###################### Hooks ######################
async runHook <T> (hookName: ServerHookName, result?: T, params?: any): Promise<T> {
delete this.registeredPlugins[plugin.npmName]
delete this.settings[plugin.npmName]
+ this.deleteTranslations(plugin.npmName)
+
if (plugin.type === PluginType.PLUGIN) {
await plugin.unregister()
css: packageJSON.css,
unregister: library ? library.unregister : undefined
}
+
+ await this.addTranslations(plugin, npmName, packageJSON.translations)
}
private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) {
return library
}
+ // ###################### Translations ######################
+
+ private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PackagePluginTranslations) {
+ for (const locale of Object.keys(translationPaths)) {
+ const path = translationPaths[locale]
+ const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path))
+
+ if (!this.translations[locale]) this.translations[locale] = {}
+ this.translations[locale][npmName] = json
+
+ logger.info('Added locale %s of plugin %s.', locale, npmName)
+ }
+ }
+
+ private deleteTranslations (npmName: string) {
+ for (const locale of Object.keys(this.translations)) {
+ delete this.translations[locale][npmName]
+
+ logger.info('Deleted locale %s of plugin %s.', locale, npmName)
+ }
+ }
+
// ###################### CSS ######################
private resetCSSGlobalFile () {
deleteLanguage: (key: string) => this.deleteConstant({ npmName, type: 'language', obj: VIDEO_LANGUAGES, key })
}
- const videoCategoryManager: PluginVideoCategoryManager= {
+ const videoCategoryManager: PluginVideoCategoryManager = {
addCategory: (key: number, label: string) => this.addConstant({ npmName, type: 'category', obj: VIDEO_CATEGORIES, key, label }),
deleteCategory: (key: number) => this.deleteConstant({ npmName, type: 'category', obj: VIDEO_CATEGORIES, key })
"library": "./main.js",
"staticDirs": {},
"css": [],
- "clientScripts": []
+ "clientScripts": [],
+ "translations": {}
}
--- /dev/null
+{
+ "Hello world": "Bonjour le monde"
+}
--- /dev/null
+{
+ "Hello world": "Ciao, mondo!"
+}
"library": "./main.js",
"staticDirs": {},
"css": [],
- "clientScripts": []
+ "clientScripts": [],
+ "translations": {
+ "fr-FR": "./languages/fr.json",
+ "it-IT": "./languages/it.json"
+ }
}
--- /dev/null
+{
+ "Hi": "Coucou"
+}
"library": "./main.js",
"staticDirs": {},
"css": [],
- "clientScripts": []
+ "clientScripts": [],
+ "translations": {
+ "fr-FR": "./languages/fr.json"
+ }
}
import './action-hooks'
import './filter-hooks'
+import './translations'
import './video-constants'
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+ cleanupTests,
+ flushAndRunMultipleServers,
+ flushAndRunServer, killallServers, reRunServer,
+ ServerInfo,
+ waitUntilLog
+} from '../../../shared/extra-utils/server/servers'
+import {
+ addVideoCommentReply,
+ addVideoCommentThread,
+ deleteVideoComment,
+ getPluginTestPath,
+ getVideosList,
+ installPlugin,
+ removeVideo,
+ setAccessTokensToServers,
+ updateVideo,
+ uploadVideo,
+ viewVideo,
+ getVideosListPagination,
+ getVideo,
+ getVideoCommentThreads,
+ getVideoThreadComments,
+ getVideoWithToken,
+ setDefaultVideoChannel,
+ waitJobs,
+ doubleFollow, getVideoLanguages, getVideoLicences, getVideoCategories, uninstallPlugin, getPluginTranslations
+} from '../../../shared/extra-utils'
+import { VideoCommentThreadTree } from '../../../shared/models/videos/video-comment.model'
+import { VideoDetails } from '../../../shared/models/videos'
+import { getYoutubeVideoUrl, importVideo } from '../../../shared/extra-utils/videos/video-imports'
+
+const expect = chai.expect
+
+describe('Test plugin translations', function () {
+ let server: ServerInfo
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await flushAndRunServer(1)
+ await setAccessTokensToServers([ server ])
+
+ await installPlugin({
+ url: server.url,
+ accessToken: server.accessToken,
+ path: getPluginTestPath()
+ })
+
+ await installPlugin({
+ url: server.url,
+ accessToken: server.accessToken,
+ path: getPluginTestPath('-two')
+ })
+ })
+
+ it('Should not have translations for locale pt', async function () {
+ const res = await getPluginTranslations({ url: server.url, locale: 'pt' })
+
+ expect(res.body).to.deep.equal({})
+ })
+
+ it('Should have translations for locale fr', async function () {
+ const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
+
+ expect(res.body).to.deep.equal({
+ 'peertube-plugin-test': {
+ 'Hi': 'Coucou'
+ },
+ 'peertube-plugin-test-two': {
+ 'Hello world': 'Bonjour le monde'
+ }
+ })
+ })
+
+ it('Should have translations of locale it', async function () {
+ const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
+
+ expect(res.body).to.deep.equal({
+ 'peertube-plugin-test-two': {
+ 'Hello world': 'Ciao, mondo!'
+ }
+ })
+ })
+
+ it('Should remove the plugin and remove the locales', async function () {
+ await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' })
+
+ {
+ const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
+
+ expect(res.body).to.deep.equal({
+ 'peertube-plugin-test': {
+ 'Hi': 'Coucou'
+ }
+ })
+ }
+
+ {
+ const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
+
+ expect(res.body).to.deep.equal({})
+ }
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
})
}
+function getPluginTranslations (parameters: {
+ url: string,
+ locale: string,
+ expectedStatus?: number
+}) {
+ const { url, locale, expectedStatus = 200 } = parameters
+ const path = '/plugins/translations/' + locale + '.json'
+
+ return makeGetRequest({
+ url,
+ path,
+ statusCodeExpected: expectedStatus
+ })
+}
+
function installPlugin (parameters: {
url: string,
accessToken: string,
listPlugins,
listAvailablePlugins,
installPlugin,
+ getPluginTranslations,
getPluginsCSS,
updatePlugin,
getPlugin,
import { PluginClientScope } from './plugin-client-scope.type'
+export type PluginTranslationPaths = {
+ [ locale: string ]: string
+}
+
export type ClientScript = {
script: string,
scopes: PluginClientScope[]
css: string[]
clientScripts: ClientScript[]
+
+ translations: PluginTranslationPaths
}
--- /dev/null
+export type PluginTranslation = {
+ [ npmName: string ]: {
+ [ key: string ]: string
+ }
+}