Bookmark and Reader bugs (#1632)

* Updated swiper and some packages for reported security issues

* Fixed reading lists promotion not working

* Refactor RenameFileForCopy to use iterative recursion, rather than functional.

* Ensured that bookmarks are fetched and ordered by Created date.

* Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly.

* Default installs to Debug log level given errors users have and Debug not being too noisy

* Added jumpbar to bookmarks page

* Now added jumpbar to bookmarks

* Refactored some code into pipes and added some debug messaging for prefetcher

* Try loading next and prev chapter's first/last page to cache so it renders faster

* Updated GetImage to do a bound check on max page.

Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes.

* Refactored the image setting code to use a single method which tries to use a cached image always.

* Refactored code to use getPage which favors cache and simplifies image creation code
This commit is contained in:
Joe Milazzo 2022-11-02 21:10:19 -04:00 committed by GitHub
parent dab42041d5
commit 38a169818b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 317 additions and 166 deletions

View File

@ -593,6 +593,23 @@ public class DirectoryServiceTests
|| outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip")));
}
[Fact]
public void CopyFilesToDirectory_ShouldRenameFilesToPassedNames()
{
const string testDirectory = "/manga/";
var fileSystem = new MockFileSystem();
fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/", new [] {"01"});
var outputFiles = ds.GetFiles("/manga/output/").Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList();
Assert.Equal(1, outputFiles.Count()); // we have 2 already there and 2 copies
// For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing)
// https://github.com/TestableIO/System.IO.Abstractions/issues/831
Assert.True(outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("/manga/output/01.zip"))
|| outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/01.zip")));
}
#endregion
#region ListDirectory

View File

@ -83,8 +83,9 @@ public class ReaderController : BaseApiController
}
/// <summary>
/// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
/// Returns an image for a given chapter. Will perform bounding checks
/// </summary>
/// <remarks>This will cache the chapter images for reading</remarks>
/// <param name="chapterId"></param>
/// <param name="page"></param>
/// <returns></returns>
@ -99,6 +100,7 @@ public class ReaderController : BaseApiController
try
{
// TODO: This code is very generic and repeated, see if we can refactor into a common method
var path = _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache.");
var format = Path.GetExtension(path).Replace(".", "");
@ -128,7 +130,6 @@ public class ReaderController : BaseApiController
if (page < 0) page = 0;
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
// NOTE: I'm not sure why I need this flow here
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
if (page > totalPages)
{
@ -139,7 +140,7 @@ public class ReaderController : BaseApiController
{
var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
var format = Path.GetExtension(path).Replace(".", "");
var format = Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;

View File

@ -1,10 +1,14 @@
namespace API.DTOs.ReadingLists;
using System;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.ReadingLists;
public class UpdateReadingListDto
{
[Required]
public int ReadingListId { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public bool Promoted { get; set; }
public bool CoverImageLocked { get; set; }
}

View File

@ -305,7 +305,7 @@ public class UserRepository : IUserRepository
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
.OrderBy(x => x.Page)
.OrderBy(x => x.Created)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -315,7 +315,7 @@ public class UserRepository : IUserRepository
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
.OrderBy(x => x.Page)
.OrderBy(x => x.Created)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -325,7 +325,7 @@ public class UserRepository : IUserRepository
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
.OrderBy(x => x.Page)
.OrderBy(x => x.Created)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -341,25 +341,27 @@ public class UserRepository : IUserRepository
{
var query = _context.AppUserBookmark
.Where(x => x.AppUserId == userId)
.OrderBy(x => x.Page)
.OrderBy(x => x.Created)
.AsNoTracking();
if (!string.IsNullOrEmpty(filter.SeriesNameQuery))
{
var seriesNameQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(filter.SeriesNameQuery);
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
{
bookmark,
series
})
.Where(o => EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%")
);
if (string.IsNullOrEmpty(filter.SeriesNameQuery))
return await query
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
query = filterSeriesQuery.Select(o => o.bookmark);
}
var seriesNameQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(filter.SeriesNameQuery);
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
{
bookmark,
series
})
.Where(o => EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%")
);
query = filterSeriesQuery.Select(o => o.bookmark);
return await query

View File

@ -79,10 +79,7 @@ public static class Seed
{
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
new()
{
Key = ServerSettingKey.LoggingLevel, Value = "Information"
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
new()
{

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -101,7 +102,7 @@ public class CacheService : ICacheService
var extractPath = GetCachePath(chapterId);
if (_directoryService.Exists(extractPath)) return chapter;
var files = chapter.Files.ToList();
var files = chapter?.Files.ToList();
ExtractChapterFiles(extractPath, files);
return chapter;
@ -223,6 +224,8 @@ public class CacheService : ICacheService
return string.Empty;
}
if (page > files.Length) page = files.Length;
// Since array is 0 based, we need to keep that in account (only affects last image)
return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page);
}
@ -234,8 +237,8 @@ public class CacheService : ICacheService
var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId);
var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList();
_directoryService.CopyFilesToDirectory(files, destDirectory);
_directoryService.Flatten(destDirectory);
_directoryService.CopyFilesToDirectory(files, destDirectory,
Enumerable.Range(1, files.Count).Select(i => i + string.Empty).ToList());
return files.Count;
}

View File

@ -37,6 +37,7 @@ public interface IDirectoryService
IEnumerable<DirectoryDto> ListDirectory(string rootPath);
Task<byte[]> ReadFileAsync(string path);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, IList<string> newFilenames);
bool Exists(string directory);
void CopyFileToDirectory(string fullFilePath, string targetDirectory);
int TraverseTreeParallelForEach(string root, Action<string> action, string searchPattern, ILogger logger);
@ -424,6 +425,46 @@ public class DirectoryService : IDirectoryService
return true;
}
/// <summary>
/// Copies files to a destination directory. If the destination directory doesn't exist, this will create it.
/// </summary>
/// <remarks>If a file already exists in dest, this will rename as (2). It does not support multiple iterations of this. Overwriting is not supported.</remarks>
/// <param name="filePaths"></param>
/// <param name="directoryPath"></param>
/// <param name="newFilenames">A list that matches one to one with filePaths. Each filepath will be renamed to newFilenames</param>
/// <returns></returns>
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, IList<string> newFilenames)
{
ExistOrCreate(directoryPath);
string currentFile = null;
var index = 0;
try
{
foreach (var file in filePaths)
{
currentFile = file;
if (!FileSystem.File.Exists(file))
{
_logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath);
continue;
}
var fileInfo = FileSystem.FileInfo.FromFileName(file);
var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(newFilenames[index] + fileInfo.Extension, directoryPath));
fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name));
index++;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false;
}
return true;
}
/// <summary>
/// Generates the combined filepath given a prepend (optional), output directory path, and a full input file path.
/// If the output file already exists, will append (1), (2), etc until it can be written out
@ -434,30 +475,32 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
private string RenameFileForCopy(string fileToCopy, string directoryPath, string prepend = "")
{
var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy);
var filename = prepend + fileInfo.Name;
var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename));
if (!targetFile.Exists)
while (true)
{
return targetFile.FullName;
}
var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy);
var filename = prepend + fileInfo.Name;
var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name);
if (FileCopyAppend.IsMatch(noExtension))
{
var match = FileCopyAppend.Match(noExtension).Value;
var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty);
noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})");
}
else
{
noExtension += " (1)";
}
var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename));
if (!targetFile.Exists)
{
return targetFile.FullName;
}
var newFilename = prepend + noExtension +
FileSystem.Path.GetExtension(fileInfo.Name);
return RenameFileForCopy(FileSystem.Path.Join(directoryPath, newFilename), directoryPath, prepend);
var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name);
if (FileCopyAppend.IsMatch(noExtension))
{
var match = FileCopyAppend.Match(noExtension).Value;
var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty);
noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})");
}
else
{
noExtension += " (1)";
}
var newFilename = prepend + noExtension + FileSystem.Path.GetExtension(fileInfo.Name);
fileToCopy = FileSystem.Path.Join(directoryPath, newFilename);
}
}
/// <summary>

View File

@ -12469,9 +12469,9 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}
@ -15417,9 +15417,9 @@
"dev": true
},
"swiper": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.6.tgz",
"integrity": "sha512-Ssyu1+FeNATF/G8e84QG+ZUNtUOAZ5vngdgxzczh0oWZPhGUVgkdv+BoePUuaCXLAFXnwVpNjgLIcGnxMdmWPA==",
"version": "8.4.4",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.4.tgz",
"integrity": "sha512-jA/8BfOZwT8PqPSnMX0TENZYitXEhNa7ZSNj1Diqh5LZyUJoBQaZcqAiPQ/PIg1+IPaRn/V8ZYVb0nxHMh51yw==",
"requires": {
"dom7": "^4.0.4",
"ssr-window": "^4.0.2"

View File

@ -44,7 +44,7 @@
"ngx-toastr": "^14.2.1",
"requires": "^1.0.2",
"rxjs": "~7.5.4",
"swiper": "^8.0.6",
"swiper": "^8.4.4",
"tslib": "^2.3.1",
"webpack-bundle-analyzer": "^4.5.0",
"zone.js": "~0.11.4"

View File

@ -36,15 +36,15 @@ export class JumpbarService {
const removalTimes = Math.ceil(removeCount / 2);
const midPoint = Math.floor(jumpBarKeys.length / 2);
jumpBarKeysToRender.push(jumpBarKeys[0]);
this.removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
this._removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
jumpBarKeysToRender.push(jumpBarKeys[midPoint]);
this.removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
this._removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
jumpBarKeysToRender.push(jumpBarKeys[jumpBarKeys.length - 1]);
return jumpBarKeysToRender;
}
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
_removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
@ -62,7 +62,7 @@ export class JumpbarService {
}
}
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
_removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
@ -80,4 +80,35 @@ export class JumpbarService {
if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]);
}
}
/**
*
* @param data An array of objects
* @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey
* @returns
*/
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {};
data.forEach(obj => {
let ch = keySelector(obj).charAt(0);
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
ch = '#';
}
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
});
return Object.keys(keys).map(k => {
return {
key: k,
size: keys[k],
title: k.toUpperCase()
}
}).sort((a, b) => {
if (a.key < b.key) return -1;
if (a.key > b.key) return 1;
return 0;
});
}
}

View File

@ -11,6 +11,7 @@
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[refresh]="refresh"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">

View File

@ -8,12 +8,14 @@ import { ConfirmService } from 'src/app/shared/confirm.service';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { FilterEvent, SeriesFilter } from 'src/app/_models/series-filter';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
@ -32,6 +34,7 @@ export class BookmarksComponent implements OnInit, OnDestroy {
downloadingSeries: {[id: number]: boolean} = {};
clearingSeries: {[id: number]: boolean} = {};
actions: ActionItem<Series>[] = [];
jumpbarKeys: Array<JumpKey> = [];
pagination!: Pagination;
filter: SeriesFilter | undefined = undefined;
@ -50,7 +53,8 @@ export class BookmarksComponent implements OnInit, OnDestroy {
private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService,
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private router: Router, private readonly cdRef: ChangeDetectorRef,
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute) {
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute,
private jumpbarService: JumpbarService) {
this.filterSettings.ageRatingDisabled = true;
this.filterSettings.collectionDisabled = true;
this.filterSettings.formatDisabled = true;
@ -158,6 +162,7 @@ export class BookmarksComponent implements OnInit, OnDestroy {
const ids = Object.keys(this.seriesIds).map(k => parseInt(k, 10));
this.seriesService.getAllSeriesByIds(ids).subscribe(series => {
this.jumpbarKeys = this.jumpbarService.getJumpKeys(series, (t: Series) => t.name);
this.series = series;
this.loadingBookmarks = false;
this.cdRef.markForCheck();

View File

@ -3,13 +3,13 @@ import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Tag } from 'src/app/_models/tag';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
@Component({
@ -30,7 +30,7 @@ export class AllCollectionsComponent implements OnInit {
constructor(private collectionService: CollectionTagService, private router: Router,
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
private titleService: Title, private utilityService: UtilityService,
private titleService: Title, private jumpbarService: JumpbarService,
private readonly cdRef: ChangeDetectorRef, public imageSerivce: ImageService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - Collections');
@ -54,7 +54,7 @@ export class AllCollectionsComponent implements OnInit {
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
this.isLoading = false;
this.jumpbarKeys = this.utilityService.getJumpKeys(tags, (t: Tag) => t.title);
this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title);
this.cdRef.markForCheck();
});
}

View File

@ -21,6 +21,7 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
import { ActionService } from 'src/app/_services/action.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
@ -124,7 +125,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title,
private modalService: NgbModal, private titleService: Title, private jumpbarService: JumpbarService,
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
@ -210,7 +211,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
this.seriesService.getAllSeries(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => {
this.series = series.result;
this.seriesPagination = series.pagination;
this.jumpbarKeys = this.utilityService.getJumpKeys(this.series, (series: Series) => series.name);
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
this.isLoading = false;
window.scrollTo(0, 0);
this.cdRef.markForCheck();

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import { LayoutMode } from '../_models/layout-mode';
@Pipe({
name: 'layoutModeIcon'
})
export class LayoutModeIconPipe implements PipeTransform {
transform(layoutMode: LayoutMode): string {
switch (layoutMode) {
case LayoutMode.Single:
return 'none';
case LayoutMode.Double:
return 'double';
case LayoutMode.DoubleReversed:
return 'double-reversed';
}
}
}

View File

@ -0,0 +1,22 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
@Pipe({
name: 'readerModeIcon'
})
export class ReaderModeIconPipe implements PipeTransform {
transform(readerMode: ReaderMode): string {
switch(readerMode) {
case ReaderMode.LeftRight:
return 'fa-exchange-alt';
case ReaderMode.UpDown:
return 'fa-exchange-alt fa-rotate-90';
case ReaderMode.Webtoon:
return 'fa-arrows-alt-v';
default:
return '';
}
}
}

View File

@ -121,7 +121,7 @@
</div>
<div class="col">
<button class="btn btn-icon" title="Reading Mode" (click)="toggleReaderMode();resetMenuCloseTimer();">
<i class="fa {{ReaderModeIcon}}" aria-hidden="true"></i>
<i class="fa {{this.readerMode | readerModeIcon}}" aria-hidden="true"></i>
<span class="visually-hidden">Reading Mode</span>
</button>
</div>
@ -197,7 +197,6 @@
</span>
</div>
</ng-container>
<!-- <div class="{{LayoutModeIconClass}}"></div> -->
</ng-container>
<select class="form-control" id="page-fitting" formControlName="layoutMode">
<option [value]="opt.value" *ngFor="let opt of layoutModes">{{opt.text}}</option>

View File

@ -1,4 +1,4 @@
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, NgZone, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
@ -290,9 +290,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
bookmarkPageHandler = this.bookmarkPage.bind(this);
getPageUrl = (pageNum: number) => {
getPageUrl = (pageNum: number, chapterId: number = this.chapterId) => {
if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum);
return this.readerService.getPageUrl(this.chapterId, pageNum);
return this.readerService.getPageUrl(chapterId, pageNum);
}
private readonly onDestroy = new Subject<void>();
@ -377,29 +377,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return 'right-side';
}
get LayoutModeIconClass() {
switch (this.layoutMode) {
case LayoutMode.Single:
return 'none';
case LayoutMode.Double:
return 'double';
case LayoutMode.DoubleReversed:
return 'double-reversed';
}
}
get ReaderModeIcon() {
switch(this.readerMode) {
case ReaderMode.LeftRight:
return 'fa-exchange-alt';
case ReaderMode.UpDown:
return 'fa-exchange-alt fa-rotate-90';
case ReaderMode.Webtoon:
return 'fa-arrows-alt-v';
default:
return '';
}
}
get ReaderMode() {
return ReaderMode;
@ -433,7 +410,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private toastr: ToastrService, private memberService: MemberService,
public utilityService: UtilityService, private renderer: Renderer2,
@Inject(DOCUMENT) private document: Document, private modalService: NgbModal,
private readonly cdRef: ChangeDetectorRef) {
private readonly cdRef: ChangeDetectorRef, private readonly ngZone: NgZone) {
this.navService.hideNavBar();
this.navService.hideSideNav();
this.cdRef.markForCheck();
@ -626,6 +603,23 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Gets a page from cache else gets a brand new Image
* @param pageNum Page Number to load
* @param forceNew Forces to fetch a new image
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId
* @returns
*/
getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {
let img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum);
if (!img || forceNew) {
img = new Image();
img.src = this.getPageUrl(this.pageNum, chapterId);
}
return img;
}
// if there is scroll room and on original, then don't paginate
checkIfPaginationAllowed() {
// This is not used atm due to the complexity it adds with keyboard.
@ -748,6 +742,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.nextChapterDisabled = true;
this.cdRef.markForCheck();
} else {
// Fetch the first page of next chapter
this.getPage(0, this.nextChapterId);
}
});
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
@ -755,6 +752,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.prevChapterDisabled = true;
this.cdRef.markForCheck();
} else {
// Fetch the last page of prev chapter
this.getPage(1000000, this.nextChapterId);
}
});
@ -961,6 +961,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
onSwipeEvent(event: any) {
console.log('Swipe event occured: ', event);
}
handlePageChange(event: any, direction: string) {
if (this.readerMode === ReaderMode.Webtoon) {
if (direction === 'right') {
@ -1073,25 +1077,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* Sets canvasImage's src to current page, but first attempts to use a pre-fetched image
*/
setCanvasImage() {
if (this.layoutMode === LayoutMode.Single) {
const img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum);
if (img) {
this.canvasImage = img; // If we tried to use this for double, then the loadPage might not render correctly when switching layout mode
} else {
this.canvasImage.src = this.getPageUrl(this.pageNum);
}
} else {
this.canvasImage.src = this.getPageUrl(this.pageNum);
}
this.canvasImage = this.getPage(this.pageNum);
this.canvasImage.onload = () => {
this.cdRef.markForCheck();
this.renderPage();
};
this.cdRef.markForCheck();
}
loadNextChapter() {
if (this.nextPageDisabled || this.nextChapterDisabled || this.bookmarkMode) {
this.toastr.info('No Next Chapter');
@ -1301,18 +1295,32 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* and also maintains page info (wide image, etc) due to onload event.
*/
prefetch() {
// NOTE: This doesn't allow for any directionality
// NOTE: This doesn't maintain 1 image behind at all times
// NOTE: I may want to provide a different prefetcher for double renderer
for(let i = 0; i <= PREFETCH_PAGES - 3; i++) {
const numOffset = this.pageNum + i;
if (numOffset > this.maxPages - 1) continue;
const index = (numOffset % this.cachedImages.length + this.cachedImages.length) % this.cachedImages.length;
if (this.readerService.imageUrlToPageNum(this.cachedImages[index].src) !== numOffset) {
this.cachedImages[index] = new Image();
this.cachedImages[index].src = this.getPageUrl(numOffset);
this.cachedImages[index].onload = () => this.cdRef.markForCheck();
this.cachedImages[index].onload = () => {
//console.log('Page ', numOffset, ' loaded');
//this.cdRef.markForCheck();
};
}
}
//console.log(this.pageNum, ' Prefetched pages: ', this.cachedImages.map(img => this.readerService.imageUrlToPageNum(img.src)));
const pages = this.cachedImages.map(img => this.readerService.imageUrlToPageNum(img.src));
const pagesBefore = pages.filter(p => p >= 0 && p < this.pageNum).length;
const pagesAfter = pages.filter(p => p >= 0 && p > this.pageNum).length;
console.log('Buffer Health: Before: ', pagesBefore, ' After: ', pagesAfter);
console.log(this.pageNum, ' Prefetched pages: ', pages.map(p => {
if (this.pageNum === p) return '[' + p + ']';
return '' + p
}));
}
@ -1327,30 +1335,54 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.setCanvasImage();
// ?! This logic is hella complex and confusing to read
// ?! We need to refactor into separate methods and keep it clean
// ?! In addition, we shouldn't update canvasImage outside of this code
if (this.layoutMode !== LayoutMode.Single) {
this.canvasImageNext.src = this.getPageUrl(this.pageNum + 1); // This needs to be capped at maxPages !this.isLastImage()
this.canvasImagePrev.src = this.getPageUrl(this.pageNum - 1);
this.canvasImageNext = new Image();
// If prev page was a spread, then we don't do + 1
console.log('Current canvas image page: ', this.readerService.imageUrlToPageNum(this.canvasImage.src));
console.log('Prev canvas image page: ', this.readerService.imageUrlToPageNum(this.canvasImage2.src));
// if (this.isWideImage(this.canvasImage2)) {
// this.canvasImagePrev = this.getPage(this.pageNum); // this.getPageUrl(this.pageNum);
// console.log('Setting Prev to ', this.pageNum);
// } else {
// this.canvasImagePrev = this.getPage(this.pageNum - 1); //this.getPageUrl(this.pageNum - 1);
// console.log('Setting Prev to ', this.pageNum - 1);
// }
// TODO: Validate this statement: This needs to be capped at maxPages !this.isLastImage()
this.canvasImageNext = this.getPage(this.pageNum + 1);
console.log('Setting Next to ', this.pageNum + 1);
this.canvasImagePrev = this.getPage(this.pageNum - 1);
console.log('Setting Prev to ', this.pageNum - 1);
if (this.pageNum + 2 < this.maxPages - 1) {
this.canvasImageAheadBy2.src = this.getPageUrl(this.pageNum + 2);
this.canvasImageAheadBy2 = this.getPage(this.pageNum + 2);
}
if (this.pageNum - 2 >= 0) {
this.canvasImageBehindBy2.src = this.getPageUrl(this.pageNum - 2 || 0);
this.canvasImageBehindBy2 = this.getPage(this.pageNum - 2 || 0);
}
if (this.ShouldRenderDoublePage || this.ShouldRenderReverseDouble) {
console.log('Rendering Double Page');
if (this.layoutMode === LayoutMode.Double) {
this.canvasImage2.src = this.canvasImageNext.src;
this.canvasImage2 = this.canvasImageNext;
} else {
this.canvasImage2.src = this.canvasImagePrev.src;
this.canvasImage2 = this.canvasImagePrev;
}
}
}
this.cdRef.markForCheck();
this.cdRef.markForCheck();
this.renderPage();
this.prefetch();
this.isLoading = false;
this.cdRef.markForCheck();
}

View File

@ -8,14 +8,18 @@ import { SharedModule } from '../shared/shared.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller.component';
import { ReaderSharedModule } from '../reader-shared/reader-shared.module';
import { FullscreenIconPipe } from './fullscreen-icon.pipe';
import { PipeModule } from '../pipe/pipe.module';
import { FullscreenIconPipe } from './_pipes/fullscreen-icon.pipe';
import { LayoutModeIconPipe } from './_pipes/layout-mode-icon.pipe';
import { ReaderModeIconPipe } from './_pipes/reader-mode-icon.pipe';
@NgModule({
declarations: [
MangaReaderComponent,
InfiniteScrollerComponent,
FullscreenIconPipe
FullscreenIconPipe,
ReaderModeIconPipe,
LayoutModeIconPipe,
],
imports: [
CommonModule,

View File

@ -56,9 +56,7 @@ export class EditReadingListModalComponent implements OnInit {
save() {
if (this.reviewGroup.value.title.trim() === '') return;
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
const apis = [this.readingListService.update(model)];
if (this.selectedCover !== '') {
@ -77,7 +75,7 @@ export class EditReadingListModalComponent implements OnInit {
togglePromotion() {
const originalPromotion = this.readingList.promoted;
this.readingList.promoted = !this.readingList.promoted;
const model = {readingListId: this.readingList.id, promoted: this.readingList.promoted};
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
this.readingListService.update(model).subscribe(res => {
/* No Operation */
}, err => {

View File

@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
import { ReadingList } from 'src/app/_models/reading-list';
@ -10,6 +9,7 @@ import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { ReadingListService } from 'src/app/_services/reading-list.service';
@Component({
@ -28,7 +28,7 @@ export class ReadingListsComponent implements OnInit {
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService,
private utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
private jumpbarService: JumpbarService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -84,7 +84,7 @@ export class ReadingListsComponent implements OnInit {
this.readingListService.getReadingLists(true).pipe(take(1)).subscribe((readingLists: PaginatedResult<ReadingList[]>) => {
this.lists = readingLists.result;
this.pagination = readingLists.pagination;
this.jumpbarKeys = this.utilityService.getJumpKeys(readingLists.result, (rl: ReadingList) => rl.title);
this.jumpbarKeys = this.jumpbarService.getJumpKeys(readingLists.result, (rl: ReadingList) => rl.title);
this.loadingLists = false;
window.scrollTo(0, 0);
this.cdRef.markForCheck();

View File

@ -203,36 +203,4 @@ export class UtilityService {
|| document.body.clientHeight;
return [windowWidth, windowHeight];
}
/**
*
* @param data An array of objects
* @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey
* @returns
*/
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {};
data.forEach(obj => {
let ch = keySelector(obj).charAt(0);
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
ch = '#';
}
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
});
return Object.keys(keys).map(k => {
return {
key: k,
size: keys[k],
title: k.toUpperCase()
}
}).sort((a, b) => {
if (a.key < b.key) return -1;
if (a.key > b.key) return 1;
return 0;
});
}
}

View File

@ -15,6 +15,7 @@ import { SeriesFilter, FilterEvent } from 'src/app/_models/series-filter';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
@ -80,7 +81,8 @@ export class WantToReadComponent implements OnInit, OnDestroy {
private seriesService: SeriesService, private titleService: Title,
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService, private hubService: MessageHubService) {
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService, private hubService: MessageHubService,
private jumpbarService: JumpbarService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Want To Read');
@ -152,7 +154,7 @@ export class WantToReadComponent implements OnInit, OnDestroy {
this.seriesService.getWantToRead(undefined, undefined, this.filter).pipe(take(1)).subscribe(paginatedList => {
this.series = paginatedList.result;
this.seriesPagination = paginatedList.pagination;
this.jumpbarKeys = this.utilityService.getJumpKeys(this.series, (series: Series) => series.name);
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
this.isLoading = false;
window.scrollTo(0, 0);
this.cdRef.markForCheck();