From 989e526abf0c0dd7958deb630df009608561bb67 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 31 May 2018 18:12:15 +0200 Subject: [PATCH] Prepare i18n files --- .gitignore | 1 + client/package.json | 4 +- client/src/app/app.component.ts | 7 +- client/src/app/app.module.ts | 20 +- .../src/app/core/routing/redirect.service.ts | 4 +- client/src/app/shared/misc/utils.ts | 2 +- client/src/app/shared/shared.module.ts | 4 +- .../+video-watch/video-watch.component.html | 66 ++-- .../+video-watch/video-watch.component.ts | 41 +- .../video-list/video-local.component.ts | 20 +- .../video-recently-added.component.ts | 20 +- .../video-list/video-search.component.ts | 21 +- .../video-list/video-trending.component.ts | 20 +- client/src/locale/source/messages_en_US.xml | 354 ++++++++++++++++++ client/src/locale/target/messages_fr.xml | 191 ++++++++++ client/yarn.lock | 53 ++- package.json | 1 + scripts/build/client.sh | 16 +- scripts/i18n/generate.sh | 11 + scripts/i18n/pull-hook.sh | 7 + scripts/release.sh | 2 +- server.ts | 14 +- server/controllers/client.ts | 40 +- shared/models/i18n/i18n.ts | 30 ++ shared/models/i18n/index.ts | 1 + shared/models/index.ts | 1 + yarn.lock | 53 +-- zanata.xml | 15 + 28 files changed, 853 insertions(+), 166 deletions(-) create mode 100644 client/src/locale/source/messages_en_US.xml create mode 100644 client/src/locale/target/messages_fr.xml create mode 100755 scripts/i18n/generate.sh create mode 100755 scripts/i18n/pull-hook.sh create mode 100644 shared/models/i18n/i18n.ts create mode 100644 shared/models/i18n/index.ts create mode 100644 zanata.xml diff --git a/.gitignore b/.gitignore index 5b5025044..92af76310 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ /logs/ /server/tools/import-mediacore.ts /docker-volume/ +/.zanata-cache diff --git a/client/package.json b/client/package.json index 61f94758a..b79a090b3 100644 --- a/client/package.json +++ b/client/package.json @@ -19,7 +19,8 @@ "ng": "ng", "postinstall": "npm rebuild node-sass && test -f angular-cli-patch.js && node angular-cli-patch.js || true", "webpack-bundle-analyzer": "webpack-bundle-analyzer", - "webdriver-manager": "webdriver-manager" + "webdriver-manager": "webdriver-manager", + "ngx-extractor": "ngx-extractor" }, "license": "GPLv3", "resolutions": { @@ -47,6 +48,7 @@ "@ngx-loading-bar/http-client": "^2.0.0", "@ngx-loading-bar/router": "^2.0.0", "@ngx-meta/core": "^6.0.0-rc.1", + "@ngx-translate/i18n-polyfill": "^1.0.0", "@types/core-js": "^0.9.28", "@types/jasmine": "^2.8.7", "@types/jasminewd2": "^2.0.3", diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 0bd127063..6087dbf80 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from '@angular/core' import { DomSanitizer, SafeHtml } from '@angular/platform-browser' -import { GuardsCheckStart, Router, NavigationEnd } from '@angular/router' +import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router' import { AuthService, RedirectService, ServerService } from '@app/core' import { isInSmallView } from '@app/shared/misc/utils' +import { is18nPath } from '../../../shared/models/i18n' @Component({ selector: 'my-app', @@ -33,7 +34,7 @@ export class AppComponent implements OnInit { private serverService: ServerService, private domSanitizer: DomSanitizer, private redirectService: RedirectService - ) {} + ) { } get serverVersion () { return this.serverService.getConfig().serverVersion @@ -53,7 +54,7 @@ export class AppComponent implements OnInit { this.router.events.subscribe(e => { if (e instanceof NavigationEnd) { const pathname = window.location.pathname - if (!pathname || pathname === '/') { + if (!pathname || pathname === '/' || is18nPath(pathname)) { this.redirectService.redirectToHomepage() } } diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index cf533629f..44552021f 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { NgModule } from '@angular/core' +import { LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT } from '@angular/core' import { BrowserModule } from '@angular/platform-browser' import { AboutModule } from '@app/about' import { ServerService } from '@app/core' @@ -16,6 +16,7 @@ import { MenuComponent } from './menu' import { SharedModule } from './shared' import { SignupModule } from './signup' import { VideosModule } from './videos' +import { buildFileLocale, getDefaultLocale } from '../../../shared/models/i18n' export function metaFactory (serverService: ServerService): MetaLoader { return new MetaStaticLoader({ @@ -61,6 +62,21 @@ export function metaFactory (serverService: ServerService): MetaLoader { AppRoutingModule // Put it after all the module because it has the 404 route ], - providers: [ ] + providers: [ + { + provide: TRANSLATIONS, + useFactory: (locale) => { + const fileLocale = buildFileLocale(locale) + + // Default locale, nothing to translate + const defaultFileLocale = buildFileLocale(getDefaultLocale()) + if (fileLocale === defaultFileLocale) return '' + + return require(`raw-loader!../locale/target/messages_${fileLocale}.xml`) + }, + deps: [ LOCALE_ID ] + }, + { provide: TRANSLATIONS_FORMAT, useValue: 'xlf' } + ] }) export class AppModule {} diff --git a/client/src/app/core/routing/redirect.service.ts b/client/src/app/core/routing/redirect.service.ts index 844f184b4..b7803cce2 100644 --- a/client/src/app/core/routing/redirect.service.ts +++ b/client/src/app/core/routing/redirect.service.ts @@ -31,7 +31,7 @@ export class RedirectService { redirectToHomepage () { console.log('Redirecting to %s...', RedirectService.DEFAULT_ROUTE) - this.router.navigate([ RedirectService.DEFAULT_ROUTE ], { replaceUrl: true }) + this.router.navigate([ RedirectService.DEFAULT_ROUTE ], { skipLocationChange: true }) .catch(() => { console.error( 'Cannot navigate to %s, resetting default route to %s.', @@ -40,7 +40,7 @@ export class RedirectService { ) RedirectService.DEFAULT_ROUTE = RedirectService.INIT_DEFAULT_ROUTE - return this.router.navigate([ RedirectService.DEFAULT_ROUTE ], { replaceUrl: true }) + return this.router.navigate([ RedirectService.DEFAULT_ROUTE ], { skipLocationChange: true }) }) } diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 11933e90b..2219ac802 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts @@ -98,7 +98,7 @@ function lineFeedToHtml (obj: object, keyToNormalize: string) { // Try to cache a little bit window.innerWidth let windowInnerWidth = window.innerWidth -// setInterval(() => windowInnerWidth = window.innerWidth, 500) +setInterval(() => windowInnerWidth = window.innerWidth, 500) function isInSmallView () { return windowInnerWidth < 600 diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 20019e47a..fba099401 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -33,6 +33,7 @@ import { VideoThumbnailComponent } from './video/video-thumbnail.component' import { VideoService } from './video/video.service' import { AccountService } from '@app/shared/account/account.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' +import { I18n } from '@ngx-translate/i18n-polyfill' @NgModule({ imports: [ @@ -108,7 +109,8 @@ import { VideoChannelService } from '@app/shared/video-channel/video-channel.ser VideoService, AccountService, MarkdownService, - VideoChannelService + VideoChannelService, + I18n ] }) export class SharedModule { } diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index 583a97562..202a12fb0 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html @@ -3,7 +3,7 @@
-
Video not found :'(
+
Video not found :'(
@@ -12,21 +12,21 @@
{{ video.name }}
-
+
{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views
@@ -38,24 +38,24 @@ *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" class="action-button action-button-like" > - +
- +
- Support + Support
- Share + Share
@@ -65,32 +65,32 @@ @@ -109,20 +109,20 @@
- Show more + Show more
- Show less + Show less
- + Privacy @@ -131,7 +131,7 @@
- + Category @@ -140,7 +140,7 @@
- + Licence @@ -149,7 +149,7 @@
- + Language @@ -158,7 +158,7 @@
- + Tags @@ -172,7 +172,7 @@
-
+
Other videos
@@ -184,13 +184,15 @@
- Friendly Reminder: + Friendly Reminder:
- The sharing system used by this video implies that some technical information about your system (such as a public IP address) can be accessed publicly. - More information + + The sharing system used by this video implies that some technical information about your system (such as a public IP address) can be accessed publicly. + + More information
-
+
OK
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index ad572ef58..f3b4f7a2b 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -23,6 +23,7 @@ import { VideoReportComponent } from './modal/video-report.component' import { VideoShareComponent } from './modal/video-share.component' import { getVideojsOptions } from '../../../assets/player/peertube-player' import { ServerService } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' @Component({ selector: 'my-video-watch', @@ -70,7 +71,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private markdownService: MarkdownService, private zone: NgZone, - private redirectService: RedirectService + private redirectService: RedirectService, + private i18n: I18n ) {} get user () { @@ -153,17 +155,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy { async blacklistVideo (event: Event) { event.preventDefault() - const res = await this.confirmService.confirm('Do you really want to blacklist this video?', 'Blacklist') + const res = await this.confirmService.confirm(this.i18n('Do you really want to blacklist this video?'), this.i18n('Blacklist')) if (res === false) return this.videoBlacklistService.blacklistVideo(this.video.id) .subscribe( status => { - this.notificationsService.success('Success', `Video ${this.video.name} had been blacklisted.`) + this.notificationsService.success( + this.i18n('Success'), + this.i18n('Video {{ videoName }} had been blacklisted.', { videoName: this.video.name }) + ) this.redirectService.redirectToHomepage() }, - error => this.notificationsService.error('Error', error.message) + error => this.notificationsService.error(this.i18n('Error'), error.message) ) } @@ -198,7 +203,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { error => { this.descriptionLoading = false - this.notificationsService.error('Error', error.message) + this.notificationsService.error(this.i18n('Error'), error.message) } ) } @@ -252,19 +257,22 @@ export class VideoWatchComponent implements OnInit, OnDestroy { async removeVideo (event: Event) { event.preventDefault() - const res = await this.confirmService.confirm('Do you really want to delete this video?', 'Delete') + const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete')) if (res === false) return this.videoService.removeVideo(this.video.id) .subscribe( status => { - this.notificationsService.success('Success', `Video ${this.video.name} deleted.`) + this.notificationsService.success( + this.i18n('Success'), + this.i18n('Video {{ videoName }} deleted.', { videoName: this.video.name }) + ) // Go back to the video-list. this.redirectService.redirectToHomepage() }, - error => this.notificationsService.error('Error', error.message) + error => this.notificationsService.error(this.i18n('Error'), error.message) ) } @@ -288,7 +296,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { } private setVideoLikesBarTooltipText () { - this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` + this.likesBarTooltipText = this.i18n( + '{{ likesNumber }} likes / {{ dislikesNumber }} dislikes', + { likesNumber: this.video.likes, dislikes: this.video.dislikes } + ) } private handleError (err: any) { @@ -298,12 +309,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { let message = '' if (errorMessage.indexOf('http error') !== -1) { - message = 'Cannot fetch video from server, maybe down.' + message = this.i18n('Cannot fetch video from server, maybe down.') } else { message = errorMessage } - this.notificationsService.error('Error', message) + this.notificationsService.error(this.i18n('Error'), message) } private checkUserRating () { @@ -318,7 +329,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { } }, - err => this.notificationsService.error('Error', err.message) + err => this.notificationsService.error(this.i18n('Error'), err.message) ) } @@ -333,8 +344,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { const res = await this.confirmService.confirm( - 'This video contains mature or explicit content. Are you sure you want to watch it?', - 'Mature or explicit content' + this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), + this.i18n('Mature or explicit content') ) if (res === false) return this.redirectService.redirectToHomepage() } @@ -399,7 +410,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.updateVideoRating(this.userRating, nextRating) this.userRating = nextRating }, - err => this.notificationsService.error('Error', err.message) + err => this.notificationsService.error(this.i18n('Error'), err.message) ) } diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts index abab7504f..03568b618 100644 --- a/client/src/app/videos/video-list/video-local.component.ts +++ b/client/src/app/videos/video-list/video-local.component.ts @@ -8,6 +8,7 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list' import { VideoSortField } from '../../shared/video/sort-field.type' import { VideoService } from '../../shared/video/video.service' import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' +import { I18n } from '@ngx-translate/i18n-polyfill' @Component({ selector: 'my-videos-local', @@ -15,18 +16,23 @@ import { VideoFilter } from '../../../../../shared/models/videos/video-query.typ templateUrl: '../../shared/video/abstract-video-list.html' }) export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage = 'Local videos' + titlePage: string currentRoute = '/videos/local' sort = '-publishedAt' as VideoSortField filter: VideoFilter = 'local' - constructor (protected router: Router, - protected route: ActivatedRoute, - protected notificationsService: NotificationsService, - protected authService: AuthService, - protected location: Location, - private videoService: VideoService) { + constructor ( + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected location: Location, + private videoService: VideoService, + private i18n: I18n + ) { super() + + this.titlePage = i18n('Local videos') } ngOnInit () { diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts index d064d9628..5768d9fe0 100644 --- a/client/src/app/videos/video-list/video-recently-added.component.ts +++ b/client/src/app/videos/video-list/video-recently-added.component.ts @@ -7,6 +7,7 @@ import { AuthService } from '../../core/auth' import { AbstractVideoList } from '../../shared/video/abstract-video-list' import { VideoSortField } from '../../shared/video/sort-field.type' import { VideoService } from '../../shared/video/video.service' +import { I18n } from '@ngx-translate/i18n-polyfill' @Component({ selector: 'my-videos-recently-added', @@ -14,17 +15,22 @@ import { VideoService } from '../../shared/video/video.service' templateUrl: '../../shared/video/abstract-video-list.html' }) export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage = 'Recently added' + titlePage: string currentRoute = '/videos/recently-added' sort: VideoSortField = '-publishedAt' - constructor (protected router: Router, - protected route: ActivatedRoute, - protected location: Location, - protected notificationsService: NotificationsService, - protected authService: AuthService, - private videoService: VideoService) { + constructor ( + protected router: Router, + protected route: ActivatedRoute, + protected location: Location, + protected notificationsService: NotificationsService, + protected authService: AuthService, + private videoService: VideoService, + private i18n: I18n + ) { super() + + this.titlePage = i18n('Recently added') } ngOnInit () { diff --git a/client/src/app/videos/video-list/video-search.component.ts b/client/src/app/videos/video-list/video-search.component.ts index aab896d84..35566a7bd 100644 --- a/client/src/app/videos/video-list/video-search.component.ts +++ b/client/src/app/videos/video-list/video-search.component.ts @@ -8,6 +8,7 @@ import { Subscription } from 'rxjs' import { AuthService } from '../../core/auth' import { AbstractVideoList } from '../../shared/video/abstract-video-list' import { VideoService } from '../../shared/video/video.service' +import { I18n } from '@ngx-translate/i18n-polyfill' @Component({ selector: 'my-videos-search', @@ -15,7 +16,7 @@ import { VideoService } from '../../shared/video/video.service' templateUrl: '../../shared/video/abstract-video-list.html' }) export class VideoSearchComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage = 'Search' + titlePage: string currentRoute = '/videos/search' loadOnInit = false @@ -24,15 +25,19 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O } private subActivatedRoute: Subscription - constructor (protected router: Router, - protected route: ActivatedRoute, - protected notificationsService: NotificationsService, - protected authService: AuthService, - protected location: Location, - private videoService: VideoService, - private redirectService: RedirectService + constructor ( + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected location: Location, + private videoService: VideoService, + private redirectService: RedirectService, + private i18n: I18n ) { super() + + this.titlePage = i18n('Search') } ngOnInit () { diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts index ea65070f9..760470e8c 100644 --- a/client/src/app/videos/video-list/video-trending.component.ts +++ b/client/src/app/videos/video-list/video-trending.component.ts @@ -7,6 +7,7 @@ import { AuthService } from '../../core/auth' import { AbstractVideoList } from '../../shared/video/abstract-video-list' import { VideoSortField } from '../../shared/video/sort-field.type' import { VideoService } from '../../shared/video/video.service' +import { I18n } from '@ngx-translate/i18n-polyfill' @Component({ selector: 'my-videos-trending', @@ -14,17 +15,22 @@ import { VideoService } from '../../shared/video/video.service' templateUrl: '../../shared/video/abstract-video-list.html' }) export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage = 'Trending' + titlePage: string currentRoute = '/videos/trending' defaultSort: VideoSortField = '-views' - constructor (protected router: Router, - protected route: ActivatedRoute, - protected notificationsService: NotificationsService, - protected authService: AuthService, - protected location: Location, - private videoService: VideoService) { + constructor ( + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected location: Location, + private videoService: VideoService, + private i18n: I18n + ) { super() + + this.titlePage = i18n('Trending') } ngOnInit () { diff --git a/client/src/locale/source/messages_en_US.xml b/client/src/locale/source/messages_en_US.xml new file mode 100644 index 000000000..6c355a97f --- /dev/null +++ b/client/src/locale/source/messages_en_US.xml @@ -0,0 +1,354 @@ + + + + + + + My public profile + + + app/menu/menu.component.ts + 17 + + + Video not found :'( + + app/videos/+video-watch/video-watch.component.ts + 6 + + + + <x id="INTERPOLATION" equiv-text="{{ video.publishedAt | myFromNow }}"/> - <x id="INTERPOLATION_1" equiv-text="{{ video.views | myNumberFormatter }}"/> views + + + app/videos/+video-watch/video-watch.component.ts + 15 + + + Go the channel page + + app/videos/+video-watch/video-watch.component.ts + 20 + + + You can subscribe to this account via any ActivityPub-capable fediverse instance. For instance with Mastodon or Pleroma you can type in the search box <strong>@<x id="INTERPOLATION" equiv-text="{{video.account.displayName}}"/>@<x id="INTERPOLATION_1" equiv-text="{{video.account.host}}"/></strong> and subscribe there. Subscription as a PeerTube user is being worked on in <a href='https://github.com/Chocobozzz/PeerTube/issues/470'>#470</a>. + + app/videos/+video-watch/video-watch.component.ts + 24 + + + By <x id="INTERPOLATION" equiv-text="{{ video.by }}"/> + + app/videos/+video-watch/video-watch.component.ts + 29 + + + Go the account page + + app/videos/+video-watch/video-watch.component.ts + 28 + + + Like this video + + app/videos/+video-watch/video-watch.component.ts + 41 + + + Dislike this video + + app/videos/+video-watch/video-watch.component.ts + 48 + + + Support + + app/videos/+video-watch/video-watch.component.ts + 53 + + + Share + + app/videos/+video-watch/video-watch.component.ts + 58 + + + Download + + app/videos/+video-watch/video-watch.component.ts + 69 + + + Download the video + + app/videos/+video-watch/video-watch.component.ts + 68 + + + Report + + app/videos/+video-watch/video-watch.component.ts + 75 + + + Report this video + + app/videos/+video-watch/video-watch.component.ts + 74 + + + Blacklist + + app/videos/+video-watch/video-watch.component.ts + 81 + + + Blacklist this video + + app/videos/+video-watch/video-watch.component.ts + 80 + + + Update + + app/videos/+video-watch/video-watch.component.ts + 87 + + + Update this video + + app/videos/+video-watch/video-watch.component.ts + 86 + + + Delete + + app/videos/+video-watch/video-watch.component.ts + 93 + + + Delete this video + + app/videos/+video-watch/video-watch.component.ts + 92 + + + Show more + + app/videos/+video-watch/video-watch.component.ts + 112 + + + Show less + + app/videos/+video-watch/video-watch.component.ts + 118 + + + + Privacy + + + app/videos/+video-watch/video-watch.component.ts + 125 + + + + Category + + + app/videos/+video-watch/video-watch.component.ts + 134 + + + + Licence + + + app/videos/+video-watch/video-watch.component.ts + 143 + + + + Language + + + app/videos/+video-watch/video-watch.component.ts + 152 + + + + Tags + + + app/videos/+video-watch/video-watch.component.ts + 161 + + + + Other videos + + + app/videos/+video-watch/video-watch.component.ts + 175 + + + Friendly Reminder: + + app/videos/+video-watch/video-watch.component.ts + 187 + + + + The sharing system used by this video implies that some technical information about your system (such as a public IP address) can be accessed publicly. + + + app/videos/+video-watch/video-watch.component.ts + 189 + + + More information + + app/videos/+video-watch/video-watch.component.ts + 192 + + + Get more information + + app/videos/+video-watch/video-watch.component.ts + 192 + + + + OK + + + app/videos/+video-watch/video-watch.component.ts + 195 + + + + Do you really want to blacklist this video? + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Success + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Video <x id="INTERPOLATION" equiv-text="{{ videoName }}"/> had been blacklisted. + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Error + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Do you really want to delete this video? + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Video <x id="INTERPOLATION" equiv-text="{{ videoName }}"/> deleted. + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + <x id="INTERPOLATION" equiv-text="{{ likesNumber }}"/> likes / <x id="INTERPOLATION_1" equiv-text="{{ dislikesNumber }}"/> dislikes + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Cannot fetch video from server, maybe down. + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + This video contains mature or explicit content. Are you sure you want to watch it? + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Mature or explicit content + + src/app/videos/+video-watch/video-watch.component.ts + 1 + + + + Local videos + + src/app/videos/video-list/video-local.component.ts + 1 + + + + Recently added + + src/app/videos/video-list/video-recently-added.component.ts + 1 + + + + Search + + src/app/videos/video-list/video-search.component.ts + 1 + + + + Trending + + src/app/videos/video-list/video-trending.component.ts + 1 + + + + + diff --git a/client/src/locale/target/messages_fr.xml b/client/src/locale/target/messages_fr.xml new file mode 100644 index 000000000..3a55922ba --- /dev/null +++ b/client/src/locale/target/messages_fr.xml @@ -0,0 +1,191 @@ + + + + + + + + My public profile + + Mon profile public + + 17 + + + + Video not found :'( + Vidéo non trouvée :'( + + 6 + + + + + - views + + + - vues + + 15 + + + + You can subscribe to this account via any ActivityPub-capable fediverse instance. For instance with Mastodon or Pleroma you can type in the search box <strong>@@</strong> and subscribe there. Subscription as a PeerTube user is being worked on in <a href='https://github.com/Chocobozzz/PeerTube/issues/470'>#470</a>. + You can subscribe to this account via any ActivityPub-capable fediverse instance. For instance with Mastodon or Pleroma you can type in the search box <strong>@@</strong> and subscribe there. Subscription as a PeerTube user is being worked on in <a href='https://github.com/Chocobozzz/PeerTube/issues/470'>#470</a>. + + 24 + + + + By + Par + + 29 + + + + Go the account page + Aller sur la page du compte + + 28 + + + + Like this video + J'aime cette vidéo + + 41 + + + + Dislike this video + Je n'aime pas cette vidéo + + 48 + + + + Support + Supporter + + 53 + + + + Share + Partager + + 58 + + + + Download + Télécharger + + 69 + + + + Download the video + Télécharger la vidéo + + 68 + + + + Report + Signaler + + 75 + + + + Report this video + Signaler cette vidéo + + 74 + + + + Blacklist + Blacklister + + 81 + + + + Blacklist this video + Blacklister cette vidéo + + 80 + + + + Update + Mettre à jour + + 87 + + + + Update this video + Mettre à jour cette vidéo + + 86 + + + + Delete + Supprimer + + 93 + + + + Delete this video + Supprimer cette vidéo + + 92 + + + + Show more + Montrer plus + + 112 + + + + Show less + Montrer moins + + 118 + + + + + Privacy + + Visibilité + + 125 + + + + + Category + + Catégorie + + 134 + + + + Trending + Tendances + + 1 + + + + \ No newline at end of file diff --git a/client/yarn.lock b/client/yarn.lock index fe2e040d8..e2d0da541 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -242,6 +242,14 @@ dependencies: tslib "~1.9.0" +"@ngx-translate/i18n-polyfill@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@ngx-translate/i18n-polyfill/-/i18n-polyfill-1.0.0.tgz#145edb28bcfc1332e1bc25279eadf9d4ed0a20f8" + dependencies: + glob "7.1.2" + tslib "^1.9.0" + yargs "10.0.3" + "@nodelib/fs.stat@^1.0.1": version "1.1.0" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.0.tgz#50c1e2260ac0ed9439a181de3725a0168d59c48a" @@ -4189,6 +4197,17 @@ glob@7.0.x: once "^1.3.0" path-is-absolute "^1.0.0" +glob@7.1.2, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^5.0.15: version "5.0.15" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" @@ -4209,17 +4228,6 @@ glob@^6.0.4: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -10594,12 +10602,35 @@ yargs-parser@^7.0.0: dependencies: camelcase "^4.1.0" +yargs-parser@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-8.1.0.tgz#f1376a33b6629a5d063782944da732631e966950" + dependencies: + camelcase "^4.1.0" + yargs-parser@^9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" dependencies: camelcase "^4.1.0" +yargs@10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-10.0.3.tgz#6542debd9080ad517ec5048fb454efe9e4d4aaae" + dependencies: + cliui "^3.2.0" + decamelize "^1.1.1" + find-up "^2.1.0" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^8.0.0" + yargs@11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.0.0.tgz#c052931006c5eee74610e5fc0354bedfd08a201b" diff --git a/package.json b/package.json index 608646e7d..21701e664 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "danger:clean:dev": "scripty", "danger:clean:prod": "scripty", "danger:clean:modules": "scripty", + "i18n:generate": "scripty", "reset-password": "node ./dist/scripts/reset-password.js", "play": "scripty", "dev": "scripty", diff --git a/scripts/build/client.sh b/scripts/build/client.sh index 305af1e5f..61ba4ea99 100755 --- a/scripts/build/client.sh +++ b/scripts/build/client.sh @@ -6,5 +6,19 @@ cd client rm -rf ./dist ./compiled -npm run ng build -- --prod --stats-json +defaultLanguage="en-US" +npm run ng build -- --output-path "dist/$defaultLanguage/" --deploy-url "/client/$defaultLanguage/" --prod --stats-json +mv "./dist/$defaultLanguage/assets" "./dist" + +languages="fr" + +for lang in "$languages"; do + npm run ng build -- --prod --i18n-file "./src/locale/target/messages_$lang.xml" --i18n-format xlf --i18n-locale "$lang" \ + --output-path "dist/$lang/" --deploy-url "/client/$lang/" + + # Do no duplicate assets + rm -r "./dist/$lang/assets" +done + NODE_ENV=production npm run webpack -- --config webpack/webpack.video-embed.js --mode production + diff --git a/scripts/i18n/generate.sh b/scripts/i18n/generate.sh new file mode 100755 index 000000000..429523ba4 --- /dev/null +++ b/scripts/i18n/generate.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eu + +cd client +npm run ng -- xi18n --i18n-locale "en-US" --output-path locale/source --out-file messages_en_US.xml +npm run ngx-extractor -- --locale "en-US" -i 'src/**/*.ts' -f xlf -o src/locale/source/messages_en_US.xml + +# Zanata does not support inner elements in , so we hack these special elements +# This regex translate the Angular elements to special entities (that we will reconvert on pull) +sed -i 's//\<x id=\1\/\>/g' src/locale/source/messages_en_US.xml \ No newline at end of file diff --git a/scripts/i18n/pull-hook.sh b/scripts/i18n/pull-hook.sh new file mode 100755 index 000000000..cb969f83c --- /dev/null +++ b/scripts/i18n/pull-hook.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eu + +# Zanata does not support inner elements in , so we hack these special elements +# This regex translate the converted elements to initial Angular elements +sed -i 's/\<x id=\([^\/]\+\?\)\/\>//g' client/src/locale/target/* \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh index 8c73a1fd6..393955264 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -57,7 +57,7 @@ git commit package.json client/package.json -m "Bumped to version $version" git tag -s -a "$version" -m "$version" npm run build -rm "./client/dist/stats.json" +rm "./client/dist/en-US/stats.json" # Creating the archives ( diff --git a/server.ts b/server.ts index bdcbb7988..c0e679b02 100644 --- a/server.ts +++ b/server.ts @@ -12,7 +12,6 @@ import * as bodyParser from 'body-parser' import * as express from 'express' import * as http from 'http' import * as morgan from 'morgan' -import * as path from 'path' import * as bitTorrentTracker from 'bittorrent-tracker' import * as cors from 'cors' import { Server as WebSocketServer } from 'ws' @@ -156,20 +155,11 @@ app.use('/', activityPubRouter) app.use('/', feedsRouter) app.use('/', webfingerRouter) -// Client files -app.use('/', clientsRouter) - // Static files app.use('/', staticRouter) -// Always serve index client page (the client is a single page application, let it handle routing) -app.use('/*', function (req, res) { - if (req.accepts(ACCEPT_HEADERS) === 'html') { - return res.sendFile(path.join(__dirname, '../client/dist/index.html')) - } - - return res.status(404).end() -}) +// Client files, last valid routes! +app.use('/', clientsRouter) // ----------- Errors ----------- diff --git a/server/controllers/client.ts b/server/controllers/client.ts index aff00fe6e..a29b51c51 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts @@ -3,17 +3,24 @@ import * as express from 'express' import { join } from 'path' import * as validator from 'validator' import { escapeHTML, readFileBufferPromise, root } from '../helpers/core-utils' -import { CONFIG, EMBED_SIZE, OPENGRAPH_AND_OEMBED_COMMENT, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers' +import { + ACCEPT_HEADERS, + CONFIG, + EMBED_SIZE, + OPENGRAPH_AND_OEMBED_COMMENT, + STATIC_MAX_AGE, + STATIC_PATHS +} from '../initializers' import { asyncMiddleware } from '../middlewares' import { VideoModel } from '../models/video/video' import { VideoPrivacy } from '../../shared/models/videos' +import { I18N_LOCALES, is18nLocale, getDefaultLocale } from '../../shared/models' const clientsRouter = express.Router() const distPath = join(root(), 'client', 'dist') const assetsImagesPath = join(root(), 'client', 'dist', 'assets', 'images') const embedPath = join(distPath, 'standalone', 'videos', 'embed.html') -const indexPath = join(distPath, 'index.html') // Special route that add OpenGraph and oEmbed tags // Do not use a template engine for a so little thing @@ -45,6 +52,16 @@ clientsRouter.use('/client/*', (req: express.Request, res: express.Response, nex res.sendStatus(404) }) +// Always serve index client page (the client is a single page application, let it handle routing) +// Try to provide the right language index.html +clientsRouter.use('/(:language)?', function (req, res) { + if (req.accepts(ACCEPT_HEADERS) === 'html') { + return res.sendFile(getIndexPath(req, req.params.language)) + } + + return res.status(404).end() +}) + // --------------------------------------------------------------------------- export { @@ -53,6 +70,19 @@ export { // --------------------------------------------------------------------------- +function getIndexPath (req: express.Request, paramLang?: string) { + let lang: string + + // Check param lang validity + if (paramLang && is18nLocale(paramLang)) { + lang = paramLang + } else { + lang = req.acceptsLanguages(Object.keys(I18N_LOCALES)) || getDefaultLocale() + } + + return join(__dirname, '../../../client/dist/' + lang + '/index.html') +} + function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) { const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName() const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid @@ -142,18 +172,18 @@ async function generateWatchHtmlPage (req: express.Request, res: express.Respons } else if (validator.isInt(videoId)) { videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId) } else { - return res.sendFile(indexPath) + return res.sendFile(getIndexPath(req)) } let [ file, video ] = await Promise.all([ - readFileBufferPromise(indexPath), + readFileBufferPromise(getIndexPath(req)), videoPromise ]) const html = file.toString() // Let Angular application handle errors - if (!video || video.privacy === VideoPrivacy.PRIVATE) return res.sendFile(indexPath) + if (!video || video.privacy === VideoPrivacy.PRIVATE) return res.sendFile(getIndexPath(req)) const htmlStringPageWithTags = addOpenGraphAndOEmbedTags(html, video) res.set('Content-Type', 'text/html; charset=UTF-8').send(htmlStringPageWithTags) diff --git a/shared/models/i18n/i18n.ts b/shared/models/i18n/i18n.ts new file mode 100644 index 000000000..2d3a1d3e2 --- /dev/null +++ b/shared/models/i18n/i18n.ts @@ -0,0 +1,30 @@ +export const I18N_LOCALES = { + 'en-US': 'English (US)', + fr: 'French' +} + +export function getDefaultLocale () { + return 'en-US' +} + +const possiblePaths = Object.keys(I18N_LOCALES).map(l => '/' + l) +export function is18nPath (path: string) { + return possiblePaths.indexOf(path) !== -1 +} + +const possibleLanguages = Object.keys(I18N_LOCALES) +export function is18nLocale (locale: string) { + return possibleLanguages.indexOf(locale) !== -1 +} + +// Only use in dev mode, so relax +// In production, the locale always match with a I18N_LANGUAGES key +export function buildFileLocale (locale: string) { + if (!is18nLocale(locale)) { + // Some working examples for development purpose + if (locale.split('-')[ 0 ] === 'en') return 'en_US' + else if (locale === 'fr') return 'fr' + } + + return locale.replace('-', '_') +} diff --git a/shared/models/i18n/index.ts b/shared/models/i18n/index.ts new file mode 100644 index 000000000..8f7cbe2c7 --- /dev/null +++ b/shared/models/i18n/index.ts @@ -0,0 +1 @@ +export * from './i18n' diff --git a/shared/models/index.ts b/shared/models/index.ts index 95bc402d6..c8ce71f17 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -3,6 +3,7 @@ export * from './activitypub' export * from './users' export * from './videos' export * from './feeds' +export * from './i18n' export * from './server/job.model' export * from './oauth-client-local.model' export * from './result-list.model' diff --git a/yarn.lock b/yarn.lock index c1fed9c60..eb06faac0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1237,7 +1237,7 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3.2: +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1: version "2.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" dependencies: @@ -1517,7 +1517,7 @@ command-exists@^1.2.2: version "1.2.6" resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.6.tgz#577f8e5feb0cb0f159cd557a51a9be1bdd76e09e" -commander@*, commander@2.15.1, commander@^2.12.1, commander@^2.13.0, commander@^2.14.1, commander@^2.15.1, commander@^2.8.1, commander@^2.9.0: +commander@*, commander@2.15.1, commander@^2.12.1, commander@^2.13.0, commander@^2.14.1, commander@^2.8.1, commander@^2.9.0: version "2.15.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" @@ -2175,12 +2175,6 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error-stack-parser@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.1.tgz#a3202b8fb03114aa9b40a0e3669e48b2b65a010a" - dependencies: - stackframe "^1.0.3" - es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14: version "0.10.43" resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.43.tgz#c705e645253210233a270869aa463a2333b7ca64" @@ -4155,7 +4149,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-yaml@^3.11.0, js-yaml@^3.4.6, js-yaml@^3.5.1, js-yaml@^3.5.4, js-yaml@^3.7.0, js-yaml@^3.8.2, js-yaml@^3.8.3, js-yaml@^3.9.0: +js-yaml@^3.4.6, js-yaml@^3.5.1, js-yaml@^3.5.4, js-yaml@^3.7.0, js-yaml@^3.8.2, js-yaml@^3.8.3, js-yaml@^3.9.0: version "3.11.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef" dependencies: @@ -6741,18 +6735,6 @@ sass-graph@^2.2.4: scss-tokenizer "^0.2.3" yargs "^7.0.0" -sass-lint-auto-fix@^0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/sass-lint-auto-fix/-/sass-lint-auto-fix-0.9.2.tgz#b8b6eb95644f7919dfea33d04c1fc19ae8f07a11" - dependencies: - chalk "^2.3.2" - commander "^2.15.1" - glob "^7.1.2" - gonzales-pe-sl "^4.2.3" - js-yaml "^3.11.0" - sass-lint "^1.12.1" - stacktrace-js "^2.0.0" - sass-lint@^1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/sass-lint/-/sass-lint-1.12.1.tgz#630f69c216aa206b8232fb2aa907bdf3336b6d83" @@ -7194,10 +7176,6 @@ source-map@0.4.x, source-map@^0.4.2, source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@0.5.6: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - source-map@0.5.x, source-map@^0.5.6, source-map@~0.5.1: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" @@ -7315,12 +7293,6 @@ stack-chain@1.3.x, stack-chain@~1.3.1: version "1.3.7" resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" -stack-generator@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.2.tgz#3c13d952a596ab9318fec0669d0a1df8b87176c7" - dependencies: - stackframe "^1.0.4" - stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -7329,25 +7301,6 @@ stack-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.1.tgz#d4f33ab54e8e38778b0ca5cfd3b3afb12db68620" -stackframe@^1.0.3, stackframe@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.0.4.tgz#357b24a992f9427cba6b545d96a14ed2cbca187b" - -stacktrace-gps@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.2.tgz#33f8baa4467323ab2bd1816efa279942ba431ccc" - dependencies: - source-map "0.5.6" - stackframe "^1.0.4" - -stacktrace-js@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.0.tgz#776ca646a95bc6c6b2b90776536a7fc72c6ddb58" - dependencies: - error-stack-parser "^2.0.1" - stack-generator "^2.0.1" - stacktrace-gps "^3.0.1" - staged-git-files@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/staged-git-files/-/staged-git-files-1.1.1.tgz#37c2218ef0d6d26178b1310719309a16a59f8f7b" diff --git a/zanata.xml b/zanata.xml new file mode 100644 index 000000000..d68b3a3ba --- /dev/null +++ b/zanata.xml @@ -0,0 +1,15 @@ + + + https://trad.framasoft.org/zanata/ + peertube + develop + xliff + ./client/src/locale/source + ./client/src/locale/target + + + + ./scripts/i18n/pull-hook.sh + + + -- 2.25.1