Bookmark Refactor (#893)

* Fixed a bug which didn't take sort direction when not changing sort field

* Added foundation for Bookmark refactor

* Code broken, need to take a break. Issue is Getting bookmark image needs authentication but UI doesn't send.

* Implemented the ability to send bookmarked files to the web. Implemented ability to clear bookmarks on disk on a re-occuring basis.

* Updated the bookmark design to have it's own card that is self contained. View bookmarks modal has been updated to better lay out the cards.

* Refactored download bookmark codes to select files from bookmark directory directly rather than open underlying files.

* Wrote the basic logic to kick start the bookmark migration.

Added Installed Version into the DB to allow us to know more accurately when to run migrations

* Implemented the ability to change the bookmarks directory

* Updated all references to BookmarkDirectory to use setting from the DB.

Updated Server Settings page to use 2 col for some rows.

* Refactored some code to DirectoryService (hasWriteAccess) and fixed up some unit tests from a previous PR.

* Treat folders that start with ._ as blacklisted.

* Implemented Reset User preferences. Some extra code to prep for the migration.

* Implemented a migration for existing bookmarks to using new filesystem based bookmarks
This commit is contained in:
Joseph Milazzo 2022-01-05 09:56:49 -08:00 committed by GitHub
parent 04ffd1ef6f
commit a1a6333f09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2006 additions and 103 deletions

View file

@ -4,4 +4,5 @@ export interface PageBookmark {
seriesId: number;
volumeId: number;
chapterId: number;
fileName: string;
}

View file

@ -1,18 +1,24 @@
import { Injectable } from '@angular/core';
import { Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { AccountService } from './account.service';
import { NavService } from './nav.service';
@Injectable({
providedIn: 'root'
})
export class ImageService {
export class ImageService implements OnDestroy {
baseUrl = environment.apiUrl;
apiKey: string = '';
public placeholderImage = 'assets/images/image-placeholder-min.png';
public errorImage = 'assets/images/error-placeholder2-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
constructor(private navSerivce: NavService) {
private onDestroy: Subject<void> = new Subject();
constructor(private navSerivce: NavService, private accountService: AccountService) {
this.navSerivce.darkMode$.subscribe(res => {
if (res) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
@ -22,6 +28,17 @@ export class ImageService {
this.errorImage = 'assets/images/error-placeholder2-min.png';
}
});
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
if (user) {
this.apiKey = user.apiKey;
}
});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
getVolumeCoverImage(volumeId: number) {
@ -41,7 +58,7 @@ export class ImageService {
}
getBookmarkedImage(chapterId: number, pageNum: number) {
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum;
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
}
updateErroredImage(event: any) {

View file

@ -36,6 +36,7 @@
<i class="fa fa-arrow-left mr-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-right" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
@ -50,6 +51,6 @@
</ul>
</div>
<div class="modal-footer">
<a class="btn btn-info" href="https://wiki.kavitareader.com/en/guides/adding-a-library" target="_blank">Help</a>
<a class="btn btn-info" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>

View file

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Stack } from 'src/app/shared/data-structures/stack';
import { LibraryService } from '../../../_services/library.service';
@ -17,6 +17,12 @@ export interface DirectoryPickerResult {
})
export class DirectoryPickerComponent implements OnInit {
@Input() startingFolder: string = '';
/**
* Url to give more information about selecting directories. Passing nothing will suppress.
*/
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library';
currentRoot = '';
folders: string[] = [];
routeStack: Stack<string> = new Stack<string>();
@ -27,7 +33,22 @@ export class DirectoryPickerComponent implements OnInit {
}
ngOnInit(): void {
this.loadChildren(this.currentRoot);
if (this.startingFolder && this.startingFolder.length > 0) {
let folders = this.startingFolder.split('/');
let folders2 = this.startingFolder.split('\\');
if (folders.length === 1 && folders2.length > 1) {
folders = folders2;
}
if (!folders[0].endsWith('/')) {
folders[0] = folders[0] + '/';
}
folders.forEach(folder => this.routeStack.push(folder));
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
} else {
this.loadChildren(this.currentRoot);
}
}
filterFolder = (folder: string) => {
@ -38,7 +59,7 @@ export class DirectoryPickerComponent implements OnInit {
this.currentRoot = folderName;
this.routeStack.push(folderName);
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
this.loadChildren(fullPath);
}
goBack() {
@ -86,7 +107,7 @@ export class DirectoryPickerComponent implements OnInit {
if (lastPath && lastPath != path) {
let replaced = path.replace(lastPath, '');
if (replaced.startsWith('/') || replaced.startsWith('\\')) {
replaced = replaced.substr(1, replaced.length);
replaced = replaced.substring(1, replaced.length);
}
return replaced;
}
@ -95,14 +116,11 @@ export class DirectoryPickerComponent implements OnInit {
}
navigateTo(index: number) {
const numberOfPops = this.routeStack.items.length - index;
if (this.routeStack.items.length - numberOfPops > this.routeStack.items.length) {
this.routeStack.items = [];
}
for (let i = 0; i < numberOfPops; i++) {
while(this.routeStack.items.length - 1 > index) {
this.routeStack.pop();
}
this.loadChildren(this.routeStack.peek() || '');
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
}
}

View file

@ -8,4 +8,5 @@ export interface ServerSettings {
enableOpds: boolean;
enableAuthentication: boolean;
baseUrl: string;
bookmarksDirectory: string;
}

View file

@ -8,6 +8,20 @@
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
</div>
<div class="form-group">
<label for="settings-bookmarksdir">Bookmarks Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
<span class="sr-only" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
<div class="input-group">
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
Change
</button>
</div>
</div>
</div>
<!-- <div class="form-group">
<label for="settings-baseurl">Base Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
@ -15,20 +29,22 @@
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
</div> -->
<div class="form-group">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
<div class="row no-gutters">
<div class="form-group col-md-6 col-sm-12 pr-2">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group col-md-6 col-sm-12">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>
</div>
<div class="form-group">
@ -78,6 +94,7 @@
</div>
<div class="float-right">
<button type="button" class="btn btn-secondary mr-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>

View file

@ -1,9 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { SettingsService } from '../settings.service';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { ServerSettings } from '../_models/server-settings';
@Component({
@ -18,7 +20,8 @@ export class ManageSettingsComponent implements OnInit {
taskFrequencies: Array<string> = [];
logLevels: Array<string> = [];
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService,
private modalService: NgbModal) { }
ngOnInit(): void {
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
@ -30,6 +33,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required]));
this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required]));
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
@ -43,6 +47,7 @@ export class ManageSettingsComponent implements OnInit {
resetForm() {
this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory);
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan);
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
@ -77,4 +82,26 @@ export class ManageSettingsComponent implements OnInit {
});
}
resetToDefaults() {
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
}, (err: any) => {
console.error('error: ', err);
});
}
openDirectoryChooser(existingDirectory: string, formControl: string) {
const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' });
modalRef.componentInstance.startingFolder = existingDirectory || '';
modalRef.componentInstance.helpUrl = '';
modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => {
if (closeResult.success) {
this.settingsForm.get(formControl)?.setValue(closeResult.folderPath);
this.settingsForm.markAsTouched();
}
});
}
}

View file

@ -21,6 +21,10 @@ export class SettingsService {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}
resetServerSettings() {
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset', {});
}
getTaskFrequencies() {
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
}

View file

@ -5,15 +5,16 @@
</button>
</div>
<div class="modal-body">
<ul class="list-unstyled">
<li class="list-group-item" *ngIf="bookmarks.length > 0">
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
</li>
<li class="list-group-item" *ngIf="bookmarks.length === 0">
No bookmarks yet
</li>
</ul>
<p *ngIf="bookmarks.length > 0; else noBookmarks">
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
</p>
<ng-template #noBookmarks>No bookmarks yet</ng-template>
<div class="row no-gutters">
<div *ngFor="let bookmark of bookmarks; let idx = index">
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">

View file

@ -51,6 +51,10 @@ export class BookmarksModalComponent implements OnInit {
this.modal.close();
}
removeBookmark(bookmark: PageBookmark, index: number) {
this.bookmarks.splice(index, 1);
}
downloadBookmarks() {
this.isDownloading = true;
this.downloadService.downloadBookmarks(this.bookmarks).pipe(

View file

@ -0,0 +1,28 @@
<div class="card" *ngIf="bookmark != undefined">
<img class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="170px">
<div class="card-body" *ngIf="bookmark.page >= 0">
<div class="header-row">
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-right" *ngIf="series != undefined">
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
<ng-container *ngIf="isClearing; else notClearing">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</ng-template>
</button>
</span>
</div>
<div>
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
.card-body {
padding: 5px;
}
.card {
margin-left: 5px;
margin-right: 5px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-overflow {
font-size: 13px;
width: 130px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
margin-top: 2px;
margin-bottom: 0px;
}

View file

@ -0,0 +1,43 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { PageBookmark } from '../../_models/page-bookmark';
@Component({
selector: 'app-bookmark',
templateUrl: './bookmark.component.html',
styleUrls: ['./bookmark.component.scss']
})
export class BookmarkComponent implements OnInit {
@Input() bookmark: PageBookmark | undefined;
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
series: Series | undefined;
isClearing: boolean = false;
isDownloading: boolean = false;
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.bookmark) {
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
this.series = series;
});
}
}
handleClick(event: any) {
}
removeBookmark() {
if (this.bookmark === undefined) return;
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
this.bookmarkRemoved.emit(this.bookmark);
this.bookmark = undefined;
});
}
}

View file

@ -20,13 +20,13 @@
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
<div>
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="sr-only">(promoted)</span>

View file

@ -8,6 +8,7 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@ -49,7 +50,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
/**
* This is the entity we are representing. It will be returned if an action is executed.
*/
@Input() entity!: Series | Volume | Chapter | CollectionTag;
@Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark;
/**
* If the entity is selected or not.
*/

View file

@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxFileDropModule } from 'ngx-file-drop';
@ -23,6 +23,7 @@ import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/b
import { PipeModule } from '../pipe/pipe.module';
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
import { FileInfoComponent } from './file-info/file-info.component';
import { BookmarkComponent } from './bookmark/bookmark.component';
@ -42,7 +43,8 @@ import { FileInfoComponent } from './file-info/file-info.component';
BulkOperationsComponent,
BulkAddToCollectionComponent,
ChapterMetadataDetailComponent,
FileInfoComponent
FileInfoComponent,
BookmarkComponent,
],
imports: [
CommonModule,

View file

@ -56,7 +56,7 @@ canvas {
.overlay {
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(10px);
backdrop-filter: blur(10px); // BUG: This doesn't work on Firefox
color: white;
}