Send To Device Support (#1557)

* Tweaked the logging output

* Started implementing some basic idea for devices

* Updated Email Service with new API routes

* Implemented basic DB structure and some APIs to prep for the UI and flows.

* Added an abstract class to make Unit testing easier.

* Removed dependency we don't need

* Updated the UI to be able to show devices and add new devices. Email field will update the platform if the user hasn't interacted with it already.

* Added ability to delete a device as well

* Basic ability to send files to devices works

* Refactored Action code to pass ActionItem back and allow for dynamic children based on an Observable (api).

Hooked in ability to send a chapter to a device. There is no logic in the FE to validate type.

* Fixed a broken unit test

* Implemented the ability to edit a device

* Code cleanup

* Fixed a bad success message

* Fixed broken unit test from updating mock layer
This commit is contained in:
Joseph Milazzo 2022-09-23 17:41:29 -05:00 committed by GitHub
parent ab0f13ef74
commit 9d7476a367
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 3026 additions and 157 deletions

View file

@ -0,0 +1,8 @@
export enum DevicePlatform {
Custom = 0,
PocketBook = 1,
Kindle = 2,
Kobo = 3
}
export const devicePlatforms = [DevicePlatform.Custom, DevicePlatform.Kindle, DevicePlatform.Kobo, DevicePlatform.PocketBook];

View file

@ -0,0 +1,9 @@
import { DevicePlatform } from "./device-platform";
export interface Device {
id: number;
name: string;
platform: DevicePlatform;
emailAddress: string;
lastUsed: string;
}

View file

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, OnDestroy } from '@angular/core';
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
import { of, ReplaySubject, Subject } from 'rxjs';
import { filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences';
@ -10,6 +10,7 @@ import { EVENTS, MessageHubService } from './message-hub.service';
import { ThemeService } from './theme.service';
import { InviteUserResponse } from '../_models/invite-user-response';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { DeviceService } from './device.service';
@Injectable({
providedIn: 'root'
@ -66,7 +67,7 @@ export class AccountService implements OnDestroy {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
}
login(model: {username: string, password: string}): Observable<any> {
login(model: {username: string, password: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
map((response: User) => {
const user = response;

View file

@ -1,15 +1,17 @@
import { Injectable } from '@angular/core';
import { map, Observable, shareReplay } from 'rxjs';
import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag';
import { Device } from '../_models/device/device';
import { Library } from '../_models/library';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
import { DeviceService } from './device.service';
export enum Action {
AddTo = -2,
Others = -1,
Submenu = -1,
/**
* Mark entity as read
*/
@ -78,14 +80,26 @@ export enum Action {
* Remove from user's Want to Read List
*/
RemoveFromWantToReadList = 16,
/**
* Send to a device
*/
SendTo = 17,
}
export interface ActionItem<T> {
title: string;
action: Action;
callback: (action: Action, data: T) => void;
callback: (action: ActionItem<T>, data: T) => void;
requiresAdmin: boolean;
children: Array<ActionItem<T>>;
/**
* Indicates that there exists a separate list will be loaded from an API
*/
dynamicList?: Observable<{title: string, data: any}[]> | undefined;
/**
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
*/
_extra?: any;
}
@Injectable({
@ -109,7 +123,7 @@ export class ActionFactoryService {
isAdmin = false;
hasDownloadRole = false;
constructor(private accountService: AccountService) {
constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
@ -123,35 +137,35 @@ export class ActionFactoryService {
});
}
getLibraryActions(callback: (action: Action, library: Library) => void) {
getLibraryActions(callback: (action: ActionItem<Library>, library: Library) => void) {
return this.applyCallbackToList(this.libraryActions, callback);
}
getSeriesActions(callback: (action: Action, series: Series) => void) {
getSeriesActions(callback: (action: ActionItem<Series>, series: Series) => void) {
return this.applyCallbackToList(this.seriesActions, callback);
}
getVolumeActions(callback: (action: Action, volume: Volume) => void) {
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
return this.applyCallbackToList(this.volumeActions, callback);
}
getChapterActions(callback: (action: Action, chapter: Chapter) => void) {
getChapterActions(callback: (action: ActionItem<Chapter>, chapter: Chapter) => void) {
return this.applyCallbackToList(this.chapterActions, callback);
}
getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) {
getCollectionTagActions(callback: (action: ActionItem<CollectionTag>, collectionTag: CollectionTag) => void) {
return this.applyCallbackToList(this.collectionTagActions, callback);
}
getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) {
getReadingListActions(callback: (action: ActionItem<ReadingList>, readingList: ReadingList) => void) {
return this.applyCallbackToList(this.readingListActions, callback);
}
getBookmarkActions(callback: (action: Action, series: Series) => void) {
getBookmarkActions(callback: (action: ActionItem<Series>, series: Series) => void) {
return this.applyCallbackToList(this.bookmarkActions, callback);
}
dummyCallback(action: Action, data: any) {}
dummyCallback(action: ActionItem<any>, data: any) {}
_resetActions() {
this.libraryActions = [
@ -163,7 +177,7 @@ export class ActionFactoryService {
children: [],
},
{
action: Action.Others,
action: Action.Submenu,
title: 'Others',
callback: this.dummyCallback,
requiresAdmin: true,
@ -212,7 +226,7 @@ export class ActionFactoryService {
children: [],
},
{
action: Action.AddTo,
action: Action.Submenu,
title: 'Add to',
callback: this.dummyCallback,
requiresAdmin: false,
@ -262,7 +276,7 @@ export class ActionFactoryService {
children: [],
},
{
action: Action.Others,
action: Action.Submenu,
title: 'Others',
callback: this.dummyCallback,
requiresAdmin: false,
@ -315,7 +329,7 @@ export class ActionFactoryService {
children: [],
},
{
action: Action.AddTo,
action: Action.Submenu,
title: 'Add to',
callback: this.dummyCallback,
requiresAdmin: false,
@ -368,7 +382,7 @@ export class ActionFactoryService {
children: [],
},
{
action: Action.AddTo,
action: Action.Submenu,
title: 'Add to',
callback: this.dummyCallback,
requiresAdmin: false,
@ -397,6 +411,24 @@ export class ActionFactoryService {
requiresAdmin: false,
children: [],
},
{
action: Action.Submenu,
title: 'Send To',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.SendTo,
title: '',
callback: this.dummyCallback,
requiresAdmin: false,
dynamicList: this.deviceService.devices$.pipe(map(devices => devices.map(d => {
return {'title': d.name, 'data': d};
}), shareReplay())),
children: []
}
],
},
];
this.readingListActions = [
@ -441,7 +473,7 @@ export class ActionFactoryService {
];
}
private applyCallback(action: ActionItem<any>, callback: (action: Action, data: any) => void) {
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
action.callback = callback;
if (action.children === null || action.children?.length === 0) return;
@ -451,7 +483,7 @@ export class ActionFactoryService {
});
}
private applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: Action, data: any) => void): Array<ActionItem<any>> {
private applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});

View file

@ -0,0 +1,48 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ReplaySubject, shareReplay, switchMap, take, tap } from 'rxjs';
import { environment } from 'src/environments/environment';
import { Device } from '../_models/device/device';
import { DevicePlatform } from '../_models/device/device-platform';
@Injectable({
providedIn: 'root'
})
export class DeviceService {
baseUrl = environment.apiUrl;
private devicesSource: ReplaySubject<Device[]> = new ReplaySubject<Device[]>(1);
public devices$ = this.devicesSource.asObservable().pipe(shareReplay());
constructor(private httpClient: HttpClient) {
this.httpClient.get<Device[]>(this.baseUrl + 'device', {}).subscribe(data => {
this.devicesSource.next(data);
});
}
createDevice(name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, {responseType: 'text' as 'json'});
}
updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, {responseType: 'text' as 'json'});
}
deleteDevice(id: number) {
return this.httpClient.delete(this.baseUrl + 'device?deviceId=' + id);
}
getDevices() {
return this.httpClient.get<Device[]>(this.baseUrl + 'device', {}).pipe(tap(data => {
this.devicesSource.next(data);
}));
}
sendTo(chapterId: number, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'});
}
}

View file

@ -2,8 +2,11 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>Email Services (SMTP)</h4>
<p>Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
email service, by setting up <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to the default. There is no way to disable emails although you are not required to use a
valid email address for users. Confirmation links will always be saved to logs and presented in the UI. Emails will not be sent if you are not accessing Kavita via a publically reachable url.
email service, by setting up <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the url of the email service and use the Test button to ensure it works.
At any time you can reset to the default. There is no way to disable emails for authentication, although you are not required to use a
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publically reachable url.
<span class="text-warning">If you want Send To device to work, you must host your own email service.</span>
</p>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>

View file

@ -68,7 +68,7 @@
<label for="logging-level-port" class="form-label">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.</ng-template>
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>

View file

@ -11,7 +11,7 @@ import { JumpKey } from '../_models/jumpbar/jump-key';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterEvent, SeriesFilter } from '../_models/series-filter';
import { Action } from '../_services/action-factory.service';
import { Action, ActionItem } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service';
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service';
@ -34,11 +34,11 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
switch (action) {
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();

View file

@ -8,6 +8,7 @@ import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT } from '@angular/common';
import { DeviceService } from './_services/device.service';
@Component({
selector: 'app-root',

View file

@ -12,15 +12,18 @@ import { ErrorInterceptor } from './_interceptors/error.interceptor';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { SidenavModule } from './sidenav/sidenav.module';
import { NavModule } from './nav/nav.module';
import { DevicesComponent } from './devices/devices.component';
// Disable Web Animations if the user's browser (such as iOS 12.5.5) does not support this.
const disableAnimations = !('animate' in document.documentElement);
if (disableAnimations) console.error("Web Animations have been disabled as your current browser does not support this.");
@NgModule({
declarations: [
AppComponent,
DevicesComponent,
],
imports: [
HttpClientModule,

View file

@ -89,8 +89,8 @@ export class BookmarksComponent implements OnInit, OnDestroy {
}
}
async handleAction(action: Action, series: Series) {
switch (action) {
async handleAction(action: ActionItem<Series>, series: Series) {
switch (action.action) {
case(Action.Delete):
this.clearBookmarks(series);
break;
@ -105,12 +105,12 @@ export class BookmarksComponent implements OnInit, OnDestroy {
}
}
bulkActionCallback = async (action: Action, data: any) => {
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('bookmark');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
const seriesIds = selectedSeries.map(item => item.id);
switch (action) {
switch (action.action) {
case Action.DownloadBookmark:
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId)), (d) => {
if (!d) {

View file

@ -11,7 +11,7 @@ import { BulkSelectionService } from '../bulk-selection.service';
})
export class BulkOperationsComponent implements OnInit, OnDestroy {
@Input() actionCallback!: (action: Action, data: any) => void;
@Input() actionCallback!: (action: ActionItem<any>, data: any) => void;
topOffset: number = 56;
hasMarkAsRead: boolean = false;
@ -41,13 +41,13 @@ export class BulkOperationsComponent implements OnInit, OnDestroy {
this.onDestory.complete();
}
handleActionCallback(action: Action, data: any) {
handleActionCallback(action: ActionItem<any>, data: any) {
this.actionCallback(action, data);
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, null);
action.callback(action, null);
}
}

View file

@ -139,7 +139,7 @@ export class BulkSelectionService {
return ret;
}
getActions(callback: (action: Action, data: any) => void) {
getActions(callback: (action: ActionItem<any>, data: any) => void) {
// checks if series is present. If so, returns only series actions
// else returns volume/chapter items
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];

View file

@ -8,6 +8,7 @@ import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { Device } from 'src/app/_models/device/device';
import { LibraryType } from 'src/app/_models/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
@ -16,6 +17,7 @@ import { Volume } from 'src/app/_models/volume';
import { AccountService } from 'src/app/_services/account.service';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { DeviceService } from 'src/app/_services/device.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service';
@ -100,7 +102,8 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService,
public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService, private readonly cdRef: ChangeDetectorRef) {
public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService, private readonly cdRef: ChangeDetectorRef,
private deviceSerivce: DeviceService) {
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntil(this.onDestroy),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
@ -166,7 +169,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
performAction(action: ActionItem<any>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action.action, chapter);
action.callback(action, chapter);
}
}
@ -196,8 +199,8 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: Action, chapter: Chapter) {
switch (action) {
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
@ -216,6 +219,14 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
case (Action.Read):
this.readChapter(chapter, false);
break;
case (Action.SendTo):
{
const device = (action._extra.data as Device);
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
});
break;
}
default:
break;
}

View file

@ -135,7 +135,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, undefined);
action.callback(action, undefined);
}
}

View file

@ -1,6 +1,6 @@
<ng-container *ngIf="actions.length > 0">
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>
@ -8,10 +8,17 @@
<ng-template #submenu let-list="list">
<ng-container *ngFor="let action of list">
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 else submenuDropdown">
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)">{{action.title}}</button>
<ng-container *ngIf="action.dynamicList != undefined; else justItem">
<ng-container *ngFor="let dynamicItem of (action.dynamicList | async)">
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
</ng-container>
</ng-container>
<ng-template #justItem>
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)">{{action.title}}</button>
</ng-template>
</ng-container>
<ng-template #submenuDropdown>
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventClick($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventClick($event)">
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{action.title}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>

View file

@ -34,13 +34,13 @@ export class CardActionablesComponent implements OnInit {
});
}
preventClick(event: any) {
preventEvent(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(event: any, action: ActionItem<any>) {
this.preventClick(event);
this.preventEvent(event);
if (typeof action.callback === 'function') {
this.actionHandler.emit(action);
@ -66,4 +66,9 @@ export class CardActionablesComponent implements OnInit {
subMenu.open();
}
performDynamicClick(event: any, action: ActionItem<any>, dynamicItem: any) {
action._extra = dynamicItem;
this.performAction(event, action);
}
}

View file

@ -280,12 +280,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
// if (this.download$ !== null) {
// this.toastr.info('Download is already in progress. Please wait.');
// return;
// }
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.download('volume', volume);
@ -300,7 +294,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
}
if (typeof action.callback === 'function') {
action.callback(action.action, this.entity);
action.callback(action, this.entity);
}
}

View file

@ -134,7 +134,7 @@ export class ListItemComponent implements OnInit, OnDestroy {
}
if (typeof action.callback === 'function') {
action.callback(action.action, this.entity);
action.callback(action, this.entity);
}
}
}

View file

@ -64,7 +64,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.actions = this.actionFactoryService.getSeriesActions((action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series));
this.cdRef.markForCheck();
}
}
@ -74,8 +74,8 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
this.onDestroy.complete();
}
handleSeriesActionCallback(action: Action, series: Series) {
switch (action) {
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
switch (action.action) {
case(Action.MarkAsRead):
this.markAsRead(series);
break;

View file

@ -59,8 +59,8 @@ export class AllCollectionsComponent implements OnInit {
});
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
switch (action.action) {
case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag;

View file

@ -58,11 +58,11 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
private onDestory: Subject<void> = new Subject<void>();
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
switch (action) {
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
@ -224,8 +224,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
this.loadPage();
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
switch (action.action) {
case(Action.Edit):
this.openEditCollectionTagModal(this.collectionTag);
break;
@ -236,7 +236,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.collectionTag);
action.callback(action, this.collectionTag);
}
}

View file

@ -0,0 +1 @@
<p>devices works!</p>

View file

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-devices',
templateUrl: './devices.component.html',
styleUrls: ['./devices.component.scss']
})
export class DevicesComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View file

@ -52,11 +52,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
active = this.tabs[0];
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
switch (action) {
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
@ -197,12 +197,12 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
}
}
handleAction(action: Action, library: Library) {
handleAction(action: ActionItem<Library>, library: Library) {
let lib: Partial<Library> = library;
if (library === undefined) {
lib = {id: this.libraryId, name: this.libraryName};
}
switch (action) {
switch (action.action) {
case(Action.Scan):
this.actionService.scanLibrary(lib);
break;
@ -216,7 +216,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, undefined);
action.callback(action, undefined);
}
}

View file

@ -109,7 +109,7 @@ export class ReadingListDetailComponent implements OnInit {
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.readingList);
action.callback(action, this.readingList);
}
}
@ -123,8 +123,8 @@ export class ReadingListDetailComponent implements OnInit {
this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, item.seriesFormat), {queryParams: params});
}
handleReadingListActionCallback(action: Action, readingList: ReadingList) {
switch(action) {
handleReadingListActionCallback(action: ActionItem<ReadingList>, readingList: ReadingList) {
switch(action.action) {
case Action.Delete:
this.deleteList(readingList);
break;

View file

@ -44,14 +44,14 @@ export class ReadingListsComponent implements OnInit {
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
}
performAction(action: ActionItem<any>, readingList: ReadingList) {
performAction(action: ActionItem<ReadingList>, readingList: ReadingList) {
if (typeof action.callback === 'function') {
action.callback(action.action, readingList);
action.callback(action, readingList);
}
}
handleReadingListActionCallback(action: Action, readingList: ReadingList) {
switch(action) {
handleReadingListActionCallback(action: ActionItem<ReadingList>, readingList: ReadingList) {
switch(action.action) {
case Action.Delete:
this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success('Reading list deleted');

View file

@ -40,6 +40,8 @@ import { PageLayoutMode } from '../_models/page-layout-mode';
import { DOCUMENT } from '@angular/common';
import { User } from '../_models/user';
import { ScrollService } from '../_services/scroll.service';
import { DeviceService } from '../_services/device.service';
import { Device } from '../_models/device/device';
interface RelatedSeris {
series: Series;
@ -158,7 +160,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
isAscendingSort: boolean = false; // TODO: Get this from User preferences
user: User | undefined;
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, data: any) => {
if (this.series === undefined) {
return;
}
@ -177,7 +179,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
const selectedSpecials = this.specials.filter((_chapter, index: number) => selectedSpecialIndexes.includes(index + ''));
const chapters = [...selectedChapterIds, ...selectedSpecials];
switch (action) {
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
this.actionInProgress = false;
@ -250,7 +252,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
public imageSerivce: ImageService, private messageHub: MessageHubService,
private readingListService: ReadingListService, public navService: NavService,
private offcanvasService: NgbOffcanvas, @Inject(DOCUMENT) private document: Document,
private changeDetectionRef: ChangeDetectorRef, private scrollService: ScrollService
private changeDetectionRef: ChangeDetectorRef, private scrollService: ScrollService,
private deviceSerivce: DeviceService
) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -330,10 +333,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.changeDetectionRef.markForCheck();
}
handleSeriesActionCallback(action: Action, series: Series) {
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
this.actionInProgress = true;
this.changeDetectionRef.markForCheck();
switch(action) {
switch(action.action) {
case(Action.MarkAsRead):
this.actionService.markSeriesAsRead(series, (series: Series) => {
this.actionInProgress = false;
@ -400,8 +403,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}
}
handleVolumeActionCallback(action: Action, volume: Volume) {
switch(action) {
handleVolumeActionCallback(action: ActionItem<Volume>, volume: Volume) {
switch(action.action) {
case(Action.MarkAsRead):
this.markVolumeAsRead(volume);
break;
@ -424,8 +427,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}
}
handleChapterActionCallback(action: Action, chapter: Chapter) {
switch (action) {
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
@ -441,6 +444,14 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case(Action.IncognitoRead):
this.openChapter(chapter, true);
break;
case (Action.SendTo):
{
const device = (action._extra.data as Device);
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
});
break;
}
default:
break;
}
@ -748,7 +759,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.series);
action.callback(action, this.series);
}
}

View file

@ -76,8 +76,8 @@ export class SideNavComponent implements OnInit, OnDestroy {
this.onDestroy.complete();
}
handleAction(action: Action, library: Library) {
switch (action) {
handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) {
case(Action.Scan):
this.actionService.scanLibrary(library);
break;
@ -95,7 +95,7 @@ export class SideNavComponent implements OnInit, OnDestroy {
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action.action, library);
action.callback(action, library);
}
}

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DevicePlatform } from 'src/app/_models/device/device-platform';
@Pipe({
name: 'devicePlatform'
})
export class DevicePlatformPipe implements PipeTransform {
transform(value: DevicePlatform): string {
switch(value) {
case DevicePlatform.Kindle: return 'Kindle';
case DevicePlatform.Kobo: return 'Kobo';
case DevicePlatform.PocketBook: return 'PocketBook';
case DevicePlatform.Custom: return 'Custom';
default: return value + '';
}
}
}

View file

@ -0,0 +1,45 @@
<div class="card">
<form [formGroup]="settingsForm" class="card-body">
<div class="row g-0 mb-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="settings-name" class="form-label">Device Name</label>
<input id="settings-name" class="form-control" formControlName="name" type="text">
<ng-container *ngIf="settingsForm.get('name')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="email" class="form-label">Email</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
<ng-template #emailTooltip>This email will be used to accept the file via Send To</ng-template>
<span class="visually-hidden" id="email-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
<input id="email" aria-describedby="email-help" class="form-control" formControlName="email" type="email" placeholder="@kindle.com">
<ng-container *ngIf="settingsForm.get('email')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.email">
This must be a valid email
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="device-platform" class="form-label">Device Platform</label>
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform">
<option *ngFor="let patform of devicePlatforms" [value]="patform">{{patform | devicePlatform}}</option>
</select>
<ng-container *ngIf="settingsForm.get('platform')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="submit" class="flex-fill btn btn-primary" (click)="addDevice()" [disabled]="!settingsForm.dirty">Save</button>
</div>
</form>
</div>

View file

@ -0,0 +1,81 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, takeUntil } from 'rxjs';
import { Device } from 'src/app/_models/device/device';
import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform';
import { DeviceService } from 'src/app/_services/device.service';
@Component({
selector: 'app-edit-device',
templateUrl: './edit-device.component.html',
styleUrls: ['./edit-device.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditDeviceComponent implements OnInit, OnChanges, OnDestroy {
@Input() device: Device | undefined;
@Output() deviceAdded: EventEmitter<void> = new EventEmitter();
@Output() deviceUpdated: EventEmitter<Device> = new EventEmitter();
settingsForm: FormGroup = new FormGroup({});
devicePlatforms = devicePlatforms;
private readonly onDestroy = new Subject<void>();
constructor(public deviceService: DeviceService, private toastr: ToastrService,
private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.settingsForm.addControl('name', new FormControl(this.device?.name || '', [Validators.required]));
this.settingsForm.addControl('email', new FormControl(this.device?.emailAddress || '', [Validators.required, Validators.email]));
this.settingsForm.addControl('platform', new FormControl(this.device?.platform || DevicePlatform.Custom, [Validators.required]));
// If user has filled in email and the platform hasn't been explicitly updated, try to update it for them
this.settingsForm.get('email')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(email => {
if (this.settingsForm.get('platform')?.dirty) return;
if (email === null || email === undefined || email === '') return;
if (email.endsWith('@kindle.com')) this.settingsForm.get('platform')?.setValue(DevicePlatform.Kindle);
else if (email.endsWith('@pbsync.com')) this.settingsForm.get('platform')?.setValue(DevicePlatform.PocketBook);
else this.settingsForm.get('platform')?.setValue(DevicePlatform.Custom);
this.cdRef.markForCheck();
});
}
ngOnChanges(changes: SimpleChanges): void {
if (this.device) {
this.settingsForm.get('name')?.setValue(this.device.name);
this.settingsForm.get('email')?.setValue(this.device.emailAddress);
this.settingsForm.get('platform')?.setValue(this.device.platform);
this.cdRef.markForCheck();
this.settingsForm.markAsPristine();
}
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
addDevice() {
if (this.device !== undefined) {
this.deviceService.updateDevice(this.device.id, this.settingsForm.value.name, this.settingsForm.value.platform, this.settingsForm.value.email).subscribe(() => {
this.settingsForm.reset();
this.toastr.success('Device updated');
this.cdRef.markForCheck();
this.deviceUpdated.emit();
})
return;
}
this.deviceService.createDevice(this.settingsForm.value.name, this.settingsForm.value.platform, this.settingsForm.value.email).subscribe(() => {
this.settingsForm.reset();
this.toastr.success('Device created');
this.cdRef.markForCheck();
this.deviceAdded.emit();
});
}
}

View file

@ -0,0 +1,39 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Device Manager</h3></div>
<div class="col-4">
<button class="btn btn-primary float-end" (click)="collapse.toggle()" [attr.aria-expanded]="!addDeviceIsCollapsed"
aria-controls="collapseExample">
<i class="fa fa-plus" aria-hidden="true"></i>&nbsp;Add
</button>
</div>
</div>
<p>
This section is for you to setup devices that cannot connect to Kavita via a web browser and instead have an email address that accepts files.
</p>
<div #collapse="ngbCollapse" [(ngbCollapse)]="addDeviceIsCollapsed">
<app-edit-device [device]="device" (deviceAdded)="loadDevices()" (deviceUpdated)="loadDevices()"></app-edit-device>
</div>
<div class="row g-0 mt-2">
<h4>Devices</h4>
<p *ngIf="devices.length == 0">
There are no devices setup yet
</p>
<ng-container *ngFor="let device of devices">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{device.name | sentenceCase}}</h5>
Platform: <h6 class="card-subtitle mb-2 text-muted">{{device.platform | devicePlatform}}</h6>
Email: <h6 class="card-subtitle mb-2 text-muted">{{device.emailAddress}}</h6>
<button class="btn btn-danger me-2" (click)="deleteDevice(device)">Delete</button>
<button class="btn btn-primary" (click)="editDevice(device)">Edit</button>
</div>
</div>
</ng-container>
</div>
</div>

View file

@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, takeUntil } from 'rxjs';
import { Device } from 'src/app/_models/device/device';
import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform';
import { DeviceService } from 'src/app/_services/device.service';
@Component({
selector: 'app-manage-devices',
templateUrl: './manage-devices.component.html',
styleUrls: ['./manage-devices.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageDevicesComponent implements OnInit, OnDestroy {
devices: Array<Device> = [];
addDeviceIsCollapsed: boolean = true;
device: Device | undefined;
private readonly onDestroy = new Subject<void>();
constructor(public deviceService: DeviceService, private toastr: ToastrService,
private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.loadDevices();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
loadDevices() {
this.addDeviceIsCollapsed = true;
this.device = undefined;
this.cdRef.markForCheck();
this.deviceService.getDevices().subscribe(devices => {
this.devices = devices;
this.cdRef.markForCheck();
});
}
deleteDevice(device: Device) {
this.deviceService.deleteDevice(device.id).subscribe(() => {
const index = this.devices.indexOf(device);
this.devices.splice(index, 1);
this.cdRef.markForCheck();
});
}
editDevice(device: Device) {
this.device = device;
this.addDeviceIsCollapsed = false;
this.cdRef.markForCheck();
}
}

View file

@ -335,6 +335,9 @@
<ng-container *ngIf="tab.fragment === 'theme'">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === 'devices'">
<app-manage-devices></app-manage-devices>
</ng-container>
</ng-template>
</li>
</ul>

View file

@ -52,6 +52,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: 'Password', fragment: 'password'},
{title: '3rd Party Clients', fragment: 'clients'},
{title: 'Theme', fragment: 'theme'},
{title: 'Devices', fragment: 'devices'},
];
active = this.tabs[0];
opdsEnabled: boolean = false;

View file

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbAccordionModule, NgbCollapseModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { UserSettingsRoutingModule } from './user-settings-routing.module';
import { ApiKeyComponent } from './api-key/api-key.component';
@ -10,7 +10,9 @@ import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
import { ThemeManagerComponent } from './theme-manager/theme-manager.component';
import { ColorPickerModule } from 'ngx-color-picker';
import { SidenavModule } from '../sidenav/sidenav.module';
import { ManageDevicesComponent } from './manage-devices/manage-devices.component';
import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
import { EditDeviceComponent } from './edit-device/edit-device.component';
@NgModule({
@ -19,6 +21,9 @@ import { SidenavModule } from '../sidenav/sidenav.module';
ApiKeyComponent,
ThemeManagerComponent,
SiteThemeProviderPipe,
ManageDevicesComponent,
DevicePlatformPipe,
EditDeviceComponent,
],
imports: [
CommonModule,
@ -27,6 +32,7 @@ import { SidenavModule } from '../sidenav/sidenav.module';
NgbAccordionModule,
NgbNavModule,
NgbTooltipModule,
NgbCollapseModule,
ColorPickerModule, // User prefernces background color

View file

@ -12,7 +12,7 @@ import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { SeriesFilter, FilterEvent } from 'src/app/_models/series-filter';
import { Action } from 'src/app/_services/action-factory.service';
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 { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service';
@ -48,11 +48,11 @@ export class WantToReadComponent implements OnInit, OnDestroy {
private onDestroy: Subject<void> = new Subject<void>();
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
switch (action) {
switch (action.action) {
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B