Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

* Hooked up the ability to view the available locales and switch to them.

* Made the localization service languages be derived from what's in langs/ directory.

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

* Fixed the bug where dashboard wasn't loading and made it so language switching is working.

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

* Converted user-stats-info-cards.component.html

* Converted user-stats.component.html

* Converted top-readers.component.html

* Converted some pipes and ensure translation is loaded before the app.

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

* Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita.

* Converted Series detail

* Lots more converted

* Lots more converted & hooked up the ability to flatten during prod build the language files.

* Lots more converted

* Lots more converted & fixed a bunch of broken pipes due to inject()

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

* Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection

* Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

* Fixed a bug where character tag badges weren't showing the name on chapter info

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

* Migrated the localization strings for the backend into a new file.

* Updated the localization service to be able to do backend localization with fallback to english.

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

* Everything is done except options and basic shakeout proves response times are good. Unit tests are broken.

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

* I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase.

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: expertjun <jtrobin@naver.com>
Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
Joe Milazzo 2023-08-03 10:33:51 -05:00 committed by GitHub
parent 670bf82c38
commit 3b23d63234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
389 changed files with 13652 additions and 7925 deletions

View file

@ -1,32 +0,0 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">KavitaPlus Features</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<h5>Current Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">Scrobble Support</li>
<li class="list-group-item">Series Recommendations</li>
<li class="list-group-item">Series Reviews</li>
<li class="list-group-item">Remove Donation on Side nav</li>
</ul>
<h5>Planned Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">More external data providers</li>
<li class="list-group-item">Webhooks</li>
<li class="list-group-item">Kobo Progress Syncing</li>
<li class="list-group-item">Trending/External rating integration</li>
<li class="list-group-item">Your ideas upvoted via FeatHub</li>
</ul>
<div class="text-muted">These feature unlock for the whole server while subscription active</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View file

@ -1,20 +0,0 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-feature-list-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './feature-list-modal.component.html',
styleUrls: ['./feature-list-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FeatureListModalComponent {
constructor(private modal: NgbActiveModal) {}
close() {
this.modal.close();
}
}

View file

@ -1,18 +1,19 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{review.username + "'s"}} Review {{review.isExternal ? '(external)' : ''}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
<ng-container *transloco="let t; read:'review-card-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('user-review', {username: review.username})}} {{review.isExternal ? t('external-mod') : ''}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
</div>
<div class="modal-footer">
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
{{t('go-to-review')}}
</a>
<button type="submit" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
</div>
<div class="modal-footer">
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
Go To Review
</a>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>
</ng-container>

View file

@ -13,11 +13,12 @@ import {ReactiveFormsModule} from "@angular/forms";
import {UserReview} from "../review-card/user-review";
import {SpoilerComponent} from "../spoiler/spoiler.component";
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-review-card-modal',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe],
imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe, TranslocoModule],
templateUrl: './review-card-modal.component.html',
styleUrls: ['./review-card-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View file

@ -1,35 +1,37 @@
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div *ngIf="isMyReview" class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" title="This is your review"></i>
<span class="visually-hidden">This is your review</span>
<ng-container *transloco="let t; read:'review-card'">
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div *ngIf="isMyReview" class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" [title]="t('your-review')"></i>
<span class="visually-hidden">{{t('your-review')}}</span>
</div>
</div>
</div>
<div class="col-md-10">
<div class="card-body">
<h6 class="card-title" [title]="review.tagline">
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
<ng-template #noTagline>
{{review.isExternal ? 'External Review' : 'Review'}}
</ng-template>
</h6>
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
</p>
<div class="col-md-10">
<div class="card-body">
<h6 class="card-title" [title]="review.tagline">
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
<ng-template #noTagline>
{{review.isExternal ? t('external-review') : t('local-review')}}
</ng-template>
</h6>
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" title="This is your review"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}%</span>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -9,11 +9,12 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
import {ImageComponent} from "../../shared/image/image.component";
import {ProviderImagePipe} from "../../pipe/provider-image.pipe";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-review-card',
standalone: true,
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe],
imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoModule],
templateUrl: './review-card.component.html',
styleUrls: ['./review-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -1,28 +1,30 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit Review</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
<ng-container *transloco="let t; read:'review-series-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="row g-0">
<label for="tagline" class="form-label">Tagline</label>
<input id="tagline" class="form-control" formControlName="tagline" />
</div>
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="row g-0">
<label for="tagline" class="form-label">{{t('tagline-label')}}</label>
<input id="tagline" class="form-control" formControlName="tagline" />
</div>
<div class="row g-0 mt-2">
<label for="review" class="form-label">Review</label>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
</div>
</form>
<div class="row g-0 mt-2">
<label for="review" class="form-label">{{t('review-label')}}</label>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
</div>
</div>
</ng-container>

View file

@ -4,11 +4,12 @@ import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap';
import { SeriesService } from 'src/app/_services/series.service';
import {UserReview} from "../review-card/user-review";
import {CommonModule} from "@angular/common";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-review-series-modal',
standalone: true,
imports: [CommonModule, NgbRating, ReactiveFormsModule],
imports: [CommonModule, NgbRating, ReactiveFormsModule, TranslocoModule],
templateUrl: './review-series-modal.component.html',
styleUrls: ['./review-series-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import {inject, Pipe, PipeTransform} from '@angular/core';
import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event";
import {TranslocoPipe, TranslocoService} from "@ngneat/transloco";
@Pipe({
name: 'scrobbleEventType',
@ -7,13 +8,20 @@ import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event";
})
export class ScrobbleEventTypePipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(value: ScrobbleEventType): string {
switch (value) {
case ScrobbleEventType.ChapterRead: return 'Reading Progress';
case ScrobbleEventType.ScoreUpdated: return 'Rating Update';
case ScrobbleEventType.AddWantToRead: return 'Want To Read: Add';
case ScrobbleEventType.RemoveWantToRead: return 'Want To Read: Remove';
case ScrobbleEventType.Review: return 'Review update';
case ScrobbleEventType.ChapterRead:
return this.translocoService.translate('scrobble-event-type-pipe.chapter-read');
case ScrobbleEventType.ScoreUpdated:
return this.translocoService.translate('scrobble-event-type-pipe.score-updated');
case ScrobbleEventType.AddWantToRead:
return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-add');
case ScrobbleEventType.RemoveWantToRead:
return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-remove');
case ScrobbleEventType.Review:
return this.translocoService.translate('scrobble-event-type-pipe.review');
}
}

View file

@ -1,7 +1,8 @@
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
<span *ngIf="isCollapsed; else show">Spoiler, click to show</span>
<ng-template #show>
<div [innerHTML]="html | safeHtml"></div>
</ng-template>
</div>
<ng-container *transloco="let t; read:'spoiler'">
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
<span *ngIf="isCollapsed; else show">{{t('click-to-show')}}</span>
<ng-template #show>
<div [innerHTML]="html | safeHtml"></div>
</ng-template>
</div>
</ng-container>

View file

@ -9,11 +9,12 @@ import {
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-spoiler',
standalone: true,
imports: [CommonModule, SafeHtmlPipe],
imports: [CommonModule, SafeHtmlPipe, TranslocoModule],
templateUrl: './spoiler.component.html',
styleUrls: ['./spoiler.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View file

@ -1,51 +1,50 @@
<h5>Scrobble History</h5>
<p>Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active
scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it
is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">Filter</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
<ng-container *transloco="let t; read:'user-scrobble-history'">
<h5>{{t('title')}}</h5>
<p>{{t('description')}}</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
</div>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="created" (sort)="updateSort($event)">
Created
{{t('created-header')}}
</th>
<th scope="col" sortable="lastModified" (sort)="updateSort($event)" direction="desc">
Last Modified
{{t('last-modified-header')}}
</th>
<th scope="col">
Type
{{t('type-header')}}
</th>
<th scope="col" sortable="seriesName" (sort)="updateSort($event)">
Series
{{t('series-header')}}
</th>
<th scope="col">
Data
{{t('data-header')}}
</th>
<th scope="col">
Is Processed
{{t('is-processed-header')}}
</th>
</tr>
</thead>
<tbody>
<tr *ngIf="events.length === 0">
<td colspan="6">No Data</td>
<td colspan="6">{{t('no-data')}}/td>
</tr>
<tr *ngFor="let item of events; let idx = index;">
<td>
@ -63,22 +62,24 @@
<td>
<ng-container [ngSwitch]="item.scrobbleEventType">
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
Volume {{item.volumeNumber}} Chapter {{item.chapterNumber}}
{{t('volume-and-chapter-num', {v: item.volumeNumber, c: item.chapterNumber})}}
</ng-container>
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
Rating {{item.rating}}
{{t('rating', {r: item.rating})}}
</ng-container>
<ng-container *ngSwitchDefault>
Not Applicable
{{t('not-applicable')}}
</ng-container>
</ng-container>
</td>
<td>
<i class="fa-regular fa-circle icon" aria-hidden="true" *ngIf="!item.isProcessed"></i>
<i class="fa-solid fa-check-circle icon" aria-hidden="true" *ngIf="item.isProcessed"></i>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">{{item.isProcessed ? 'Processed' : 'Not Processed'}}</span>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
</tbody>
</table>
</ng-container>

View file

@ -2,21 +2,21 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec
import {CommonModule} from '@angular/common';
import {ScrobblingService} from "../../_services/scrobbling.service";
import {shareReplay} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
import {NgbPagination} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
import {debounceTime, map, take, tap} from "rxjs/operators";
import {debounceTime, take} from "rxjs/operators";
import {PaginatedResult, Pagination} from "../../_models/pagination";
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader],
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush