Misc Fixes (#2155)

* Fixed default token key not being long enough and Kavita auto-generating

* When scheduling nightly backup job, make it run at 2am to ensure everything else has ran.

* Made the overlay system work better on mobile. In order to do this, had to implement my own copy button.

* Tweaked the code to ensure we clear the selection doing anything and clicking off the overlay clears more reliably.

* Cleaned up the overlay code

* Added the ability to view the series that a rating is representing. Requires Kavita+ deployment.

* When calculating overall average rating of server, if only review is yours, don't include it.

When calculating overall average rating of server, scale to percentage (* 20) to match all other rating scales.

* Fixed side nav on mobile without donate link not fully covering the height of the screen

* Only trigger the task conversion warning on Media screen if you've touched the appropriate control.

* Fixed a bug where bookmark directory wasn't able to be changed.

* Fixed a bug where see More wouldn't show if there were just characters due to missing that check.

* Fixed a typo in documentation

* If a chapter has a range 1-6 and is fully read, when calculating highest chapter for Scrobbling, use the 6.
This commit is contained in:
Joe Milazzo 2023-07-23 11:53:16 -05:00 committed by GitHub
parent 9e04276dfd
commit 52d19642f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 121 additions and 52 deletions

View File

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs;
using API.Extensions;
using API.Services.Plus;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
@ -69,7 +70,7 @@ public class RatingController : BaseApiController
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId),
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()),
FavoriteCount = 0
});
}

View File

@ -7,4 +7,5 @@ public class RatingDto
public int AverageScore { get; set; }
public int FavoriteCount { get; set; }
public ScrobbleProvider Provider { get; set; }
public string? ProviderUrl { get; set; }
}

View File

@ -6,6 +6,7 @@ using API.Data.ManualMigrations;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -164,9 +165,9 @@ public class AppUserProgressRepository : IAppUserProgressRepository
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
.Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId &&
p.appUserProgresses.PagesRead >= p.chapter.Pages)
.Select(p => p.chapter.Number)
.Select(p => p.chapter.Range)
.ToListAsync();
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Where(d => d != null).Max(d => (int) Math.Floor(float.Parse(d)));
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Where(d => d != null).Max(d => (int) Math.Floor(Parser.MaxNumberFromRange(d)));
}
public async Task<int> GetHighestFullyReadVolumeForSeries(int seriesId, int userId)

View File

@ -137,7 +137,7 @@ public interface ISeriesRepository
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
Task<SeriesDto?> GetSeriesDtoByNamesAndMetadataIdsForUser(int userId, IEnumerable<string> names, LibraryType libraryType, string aniListUrl, string malUrl);
Task<int> GetAverageUserRating(int seriesId);
Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
}
@ -1682,12 +1682,19 @@ public class SeriesRepository : ISeriesRepository
/// Returns the Average rating for all users within Kavita instance
/// </summary>
/// <param name="seriesId"></param>
public async Task<int> GetAverageUserRating(int seriesId)
public async Task<int> GetAverageUserRating(int seriesId, int userId)
{
// If there is 0 or 1 rating and that rating is you, return 0 back
var countOfRatingsThatAreUser = await _context.AppUserRating
.Where(r => r.SeriesId == seriesId).CountAsync(u => u.AppUserId == userId);
if (countOfRatingsThatAreUser == 1)
{
return 0;
}
var avg = (await _context.AppUserRating
.Where(r => r.SeriesId == seriesId)
.AverageAsync(r => (int?) r.Rating));
return avg.HasValue ? (int) avg.Value : 0;
return avg.HasValue ? (int) (avg.Value * 20) : 0;
}
public async Task RemoveFromOnDeck(int seriesId, int userId)

View File

@ -127,7 +127,13 @@ public class TaskScheduler : ITaskScheduler
if (setting != null)
{
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
var schedule = CronConverter.ConvertToCronNotation(setting);
if (schedule == Cron.Daily())
{
// Override daily and make 2am so that everything on system has cleaned up and no blocking
schedule = Cron.Daily(2);
}
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => schedule, RecurringJobOptions);
}
else
{

View File

@ -65,7 +65,7 @@ public class PresenceTracker : IPresenceTracker
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
}
catch (Exception ex)
catch (Exception)
{
// Swallow the exception
}

View File

@ -100,7 +100,7 @@ public static class Configuration
{
try
{
return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key";
return !GetJwtToken(GetAppSettingFilename()).StartsWith("super secret unguessable key");
}
catch (Exception ex)
{

View File

@ -5,4 +5,5 @@ export interface Rating {
meanScore: number;
favoriteCount: number;
provider: ScrobbleProvider;
providerUrl: string | undefined;
}

View File

@ -4,7 +4,7 @@
<div class="row g-0">
<p>WebP/AVIF can drastically reduce space requirements for files. WebP/AVIF is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use WebP</a> or <a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">Can I Use AVIF</a>.
<b>You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.</b></p>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the media conversion task in Tasks Tab.</div>
<div *ngIf="settingsForm.get('encodeMediaAs')?.dirty" class="alert alert-danger" role="alert">You must trigger the media conversion task in Tasks Tab.</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="settings-media-encodeMediaAs" class="form-label me-1">Save Media As</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>

View File

@ -47,6 +47,7 @@ export class ManageMediaSettingsComponent implements OnInit {
saveSettings() {
const modelSettings = Object.assign({}, this.serverSettings);
modelSettings.encodeMediaAs = parseInt(this.settingsForm.get('encodeMediaAs')?.value, 10);
modelSettings.bookmarksDirectory = this.settingsForm.get('bookmarksDirectory')?.value;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;

View File

@ -5,10 +5,25 @@
<ng-container [ngSwitch]="mode">
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
<div class="row g-0">
<button class="btn btn-icon" (click)="switchMode(BookLineOverlayMode.Bookmark)">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
<span class="visually-hidden">Create Bookmark</span>
</button>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="copy()">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
<div>Copy</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
<div>Bookmark</div>
</button>
</div>
<div class="col-auto">
<button class="btn btn-icon btn-sm" (click)="reset()">
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
<span class="visually-hidden">Close</span>
</button>
</div>
</div>
</ng-container>
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">

View File

@ -8,12 +8,13 @@ import {
OnInit, Output,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {fromEvent, of} from "rxjs";
import {fromEvent, merge, of} from "rxjs";
import {catchError, filter, tap} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import getBoundingClientRect from "@popperjs/core/lib/dom-utils/getBoundingClientRect";
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {ReaderService} from "../../../_services/reader.service";
import {ToastrService} from "ngx-toastr";
enum BookLineOverlayMode {
None = 0,
@ -39,6 +40,7 @@ export class BookLineOverlayComponent implements OnInit {
xPath: string = '';
selectedText: string = '';
previousSelection: string = '';
overlayPosition: { top: number; left: number } = { top: 0, left: 0 };
mode: BookLineOverlayMode = BookLineOverlayMode.None;
bookmarkForm: FormGroup = new FormGroup({
@ -50,47 +52,56 @@ export class BookLineOverlayComponent implements OnInit {
private readonly readerService = inject(ReaderService);
get BookLineOverlayMode() { return BookLineOverlayMode; }
constructor(private elementRef: ElementRef) {}
constructor(private elementRef: ElementRef, private toastr: ToastrService) {}
ngOnInit() {
if (this.parent) {
fromEvent<MouseEvent>(this.parent.nativeElement, 'mouseup')
.pipe(takeUntilDestroyed(this.destroyRef),
tap((event: MouseEvent) => {
const selection = window.getSelection();
if (!event.target) return;
if (this.mode !== BookLineOverlayMode.None && (!selection || selection.toString().trim() === '')) {
this.reset();
return;
}
const mouseUp$ = fromEvent<MouseEvent>(this.parent.nativeElement, 'mouseup');
const touchEnd$ = fromEvent<TouchEvent>(this.parent.nativeElement, 'touchend');
this.selectedText = selection ? selection.toString().trim() : '';
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
// Get x,y coord so we can position overlay
if (event.target) {
const range = selection!.getRangeAt(0)
const rect = range.getBoundingClientRect();
const box = getBoundingClientRect(event.target as Element);
this.xPath = this.readerService.getXPathTo(event.target);
if (this.xPath !== '') {
this.xPath = '//' + this.xPath;
}
this.overlayPosition = {
top: rect.top + window.scrollY - 64 - rect.height, // 64px is the top menu area
left: rect.left + window.scrollX + 30 // Adjust 10 to center the overlay box horizontally
};
}
}
this.cdRef.markForCheck();
}))
.subscribe();
merge(mouseUp$, touchEnd$)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event: MouseEvent | TouchEvent) => {
this.handleEvent(event);
});
}
}
handleEvent(event: MouseEvent | TouchEvent) {
const selection = window.getSelection();
if (!event.target) return;
if ((!selection || selection.toString().trim() === '' || selection.toString().trim() === this.selectedText)) {
this.reset();
return;
}
this.selectedText = selection ? selection.toString().trim() : '';
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
// Get x,y coord so we can position overlay
if (event.target) {
const range = selection!.getRangeAt(0)
const rect = range.getBoundingClientRect();
const box = getBoundingClientRect(event.target as Element);
this.xPath = this.readerService.getXPathTo(event.target);
if (this.xPath !== '') {
this.xPath = '//' + this.xPath;
}
this.overlayPosition = {
top: rect.top + window.scrollY - 64 - rect.height, // 64px is the top menu area
left: rect.left + window.scrollX + 30 // Adjust 10 to center the overlay box horizontally
};
event.preventDefault();
event.stopPropagation();
}
}
this.cdRef.markForCheck();
}
switchMode(mode: BookLineOverlayMode) {
this.mode = mode;
this.cdRef.markForCheck();
@ -122,7 +133,21 @@ export class BookLineOverlayComponent implements OnInit {
this.mode = BookLineOverlayMode.None;
this.xPath = '';
this.selectedText = '';
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
this.cdRef.markForCheck();
}
async copy() {
const selection = window.getSelection();
if (selection) {
await navigator.clipboard.writeText(selection.toString());
this.toastr.info('Copied to clipboard');
}
this.reset();
}
}

View File

@ -10,7 +10,7 @@ import { NgIf, NgTemplateOutlet } from '@angular/common';
import { SplashContainerComponent } from '../splash-container/splash-container.component';
/**
* This is exclusivly used to register the first user on the server and nothing else
* This is exclusively used to register the first user on the server and nothing else
*/
@Component({
selector: 'app-register',
@ -28,9 +28,9 @@ export class RegisterComponent {
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6), Validators.pattern("^.{6,32}$")]),
});
constructor(private router: Router, private accountService: AccountService,
constructor(private router: Router, private accountService: AccountService,
private toastr: ToastrService, private memberService: MemberService) {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
if (adminExists) {
this.router.navigateByUrl('login');

View File

@ -30,5 +30,6 @@
</ng-template>
<ng-template #externalPopContent let-rating="rating">
<i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}
<div><i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}</div>
<a *ngIf="rating.providerUrl" [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">Entry</a>
</ng-template>

View File

@ -74,8 +74,10 @@ export class SeriesMetadataDetailComponent implements OnChanges {
this.seriesMetadata.letterers.length > 0 ||
this.seriesMetadata.pencillers.length > 0 ||
this.seriesMetadata.publishers.length > 0 ||
this.seriesMetadata.characters.length > 0 ||
this.seriesMetadata.translators.length > 0;
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}

View File

@ -120,6 +120,10 @@
border-top-left-radius: var(--side-nav-border-radius);
border-top-right-radius: var(--side-nav-border-radius);
}
&.no-donate {
height: calc((var(--vh)*100) - 56px);
}
}
.side-nav-overlay {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.5.1"
"version": "0.7.5.2"
},
"servers": [
{
@ -14095,6 +14095,10 @@
"type": "integer",
"description": "Misleading name but is the source of data (like a review coming from AniList)",
"format": "int32"
},
"providerUrl": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false