Directory Picker Rework (#1325)

* Started on the directory picker refactor.

* Coded some basic working version. Needs styling and variable cleanup

* code cleanup

* Implemented the ability to expose swagger on non-development servers.

* Implemented the ability to expose swagger on non-development servers.
This commit is contained in:
Joseph Milazzo 2022-06-16 12:08:09 -05:00 committed by GitHub
parent 0f5a7ee6fa
commit 9c851b0f0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 189 additions and 85 deletions

View File

@ -8,6 +8,7 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.JumpBar;
using API.DTOs.Search;
using API.DTOs.System;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -89,11 +90,15 @@ namespace API.Controllers
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("list")]
public ActionResult<IEnumerable<string>> GetDirectories(string path)
public ActionResult<IEnumerable<DirectoryDto>> GetDirectories(string path)
{
if (string.IsNullOrEmpty(path))
{
return Ok(Directory.GetLogicalDrives());
return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto()
{
Name = d,
FullPath = d
}));
}
if (!Directory.Exists(path)) return BadRequest("This is not a valid path");

View File

@ -206,6 +206,12 @@ namespace API.Controllers
}
}
if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
{
setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;

View File

@ -40,5 +40,9 @@ namespace API.DTOs.Settings
public string InstallVersion { get; set; }
public bool ConvertBookmarkToWebP { get; set; }
/// <summary>
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
public bool EnableSwaggerUi { get; set; }
}
}

View File

@ -0,0 +1,13 @@
namespace API.DTOs.System;
public class DirectoryDto
{
/// <summary>
/// Name of the directory
/// </summary>
public string Name { get; set; }
/// <summary>
/// Full Directory Path
/// </summary>
public string FullPath { get; set; }
}

View File

@ -101,6 +101,7 @@ namespace API.Data
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"},
}.ToArray());
foreach (var defaultSetting in DefaultSettings)

View File

@ -81,5 +81,10 @@ namespace API.Entities.Enums
/// </summary>
[Description("ConvertBookmarkToWebP")]
ConvertBookmarkToWebP = 14,
/// <summary>
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
[Description("EnableSwaggerUi")]
EnableSwaggerUi = 15,
}
}

View File

@ -51,6 +51,9 @@ namespace API.Helpers.Converters
case ServerSettingKey.ConvertBookmarkToWebP:
destination.ConvertBookmarkToWebP = bool.Parse(row.Value);
break;
case ServerSettingKey.EnableSwaggerUi:
destination.EnableSwaggerUi = bool.Parse(row.Value);
break;
}
}

View File

@ -6,6 +6,8 @@ using System.IO.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.DTOs.System;
using API.Entities.Enums;
using API.Extensions;
using Microsoft.Extensions.Logging;
@ -29,7 +31,7 @@ namespace API.Services
/// </summary>
/// <param name="rootPath">Absolute path of directory to scan.</param>
/// <returns>List of folder names</returns>
IEnumerable<string> ListDirectory(string rootPath);
IEnumerable<DirectoryDto> ListDirectory(string rootPath);
Task<byte[]> ReadFileAsync(string path);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
bool Exists(string directory);
@ -434,14 +436,18 @@ namespace API.Services
/// </summary>
/// <param name="rootPath"></param>
/// <returns></returns>
public IEnumerable<string> ListDirectory(string rootPath)
public IEnumerable<DirectoryDto> ListDirectory(string rootPath)
{
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<DirectoryDto>.Empty;
var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath);
var dirs = di.GetDirectories()
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
.Select(d => d.Name).ToImmutableList();
.Select(d => new DirectoryDto()
{
Name = d.Name,
FullPath = d.FullName,
}).ToImmutableList();
return dirs;
}

View File

@ -176,13 +176,23 @@ namespace API
app.UseMiddleware<ExceptionMiddleware>();
if (env.IsDevelopment())
Task.Run(async () =>
{
var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync())
.EnableSwaggerUi;
if (env.IsDevelopment() || allowSwaggerUi)
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version);
});
}
});
if (env.IsDevelopment())
{
app.UseHangfireDashboard();
}

View File

@ -0,0 +1,4 @@
export interface DirectoryDto {
name: string;
fullPath: string;
}

View File

@ -6,6 +6,7 @@ import { environment } from 'src/environments/environment';
import { JumpKey } from '../_models/jumpbar/jump-key';
import { Library, LibraryType } from '../_models/library';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { DirectoryDto } from '../_models/system/directory-dto';
@Injectable({
@ -56,7 +57,7 @@ export class LibraryService {
query = '?path=' + encodeURIComponent(rootPath);
}
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
return this.httpClient.get<DirectoryDto[]>(this.baseUrl + 'library/list' + query);
}
getJumpBar(libraryId: number) {

View File

@ -3,16 +3,32 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<!-- <div class="mb-3">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
</div> -->
<div class="mb-3">
<label for="filter" class="form-label">Path</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
(ngModelChange)="updateTable()" #instance="ngbTypeahead" placeholder="Start typing or select path"
[resultTemplate]="rt" />
</div>
<ng-template #rt let-r="result" let-t="term">
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
</ng-template>
</div>
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
*ngFor="let route of routeStack.items; let index = index">
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
{{route}}
</ng-container>
@ -22,30 +38,34 @@
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.
</div>
</ng-template>
</nav>
<ul class="list-group">
<div class="list-group-item list-group-item-action">
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
<i class="fa fa-arrow-left me-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-end" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
<ul class="list-group scrollable">
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
<span>{{getStem(folder)}}</span>
<button type="button" class="btn btn-primary float-end" (click)="shareFolder(folder, $event)">Share</button>
</button>
<div class="list-group-item text-center" *ngIf="folders.length === 0">
There are no folders here
</div>
</ul>
<table class="table table-striped scrollable">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr (click)="goBack()">
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
<td>...</td>
</tr>
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)">
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
<td id="folder--{{idx}}">
{{folder.name}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="share()">Share</button>
</div>

View File

@ -13,3 +13,7 @@ $breadcrumb-divider: quote(">");
.btn-outline-secondary {
border: 1px solid #ced4da;
}
.table {
background-color: lightgrey;
}

View File

@ -1,6 +1,8 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { catchError, debounceTime, distinctUntilChanged, filter, map, merge, Observable, of, OperatorFunction, Subject, switchMap, tap } from 'rxjs';
import { Stack } from 'src/app/shared/data-structures/stack';
import { DirectoryDto } from 'src/app/_models/system/directory-dto';
import { LibraryService } from '../../../_services/library.service';
@ -10,6 +12,7 @@ export interface DirectoryPickerResult {
}
@Component({
selector: 'app-directory-picker',
templateUrl: './directory-picker.component.html',
@ -24,9 +27,40 @@ export class DirectoryPickerComponent implements OnInit {
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita';
currentRoot = '';
folders: string[] = [];
folders: DirectoryDto[] = [];
routeStack: Stack<string> = new Stack<string>();
filterQuery: string = '';
path: string = '';
@ViewChild('instance', {static: true}) instance!: NgbTypeahead;
focus$ = new Subject<string>();
click$ = new Subject<string>();
searching: boolean = false;
searchFailed: boolean = false;
search: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) => {
const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged());
const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
const inputFocus$ = this.focus$;
return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$, text$).pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => this.searching = true),
switchMap(term =>
this.libraryService.listDirectories(this.path).pipe(
tap(() => this.searchFailed = false),
tap((folders) => this.folders = folders),
map(folders => folders.map(f => f.fullPath)),
catchError(() => {
this.searchFailed = true;
return of([]);
}))
),
tap(() => this.searching = false)
)
}
constructor(public modal: NgbActiveModal, private libraryService: LibraryService) {
@ -51,15 +85,16 @@ export class DirectoryPickerComponent implements OnInit {
}
}
filterFolder = (folder: string) => {
return folder.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
updateTable() {
this.loadChildren(this.path);
}
selectNode(folderName: string) {
this.currentRoot = folderName;
this.routeStack.push(folderName);
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
selectNode(folder: DirectoryDto) {
this.currentRoot = folder.name;
this.routeStack.push(folder.name);
this.path = folder.fullPath;
this.loadChildren(this.path);
}
goBack() {
@ -77,7 +112,6 @@ export class DirectoryPickerComponent implements OnInit {
loadChildren(path: string) {
this.libraryService.listDirectories(path).subscribe(folders => {
this.filterQuery = '';
this.folders = folders;
}, err => {
// If there was an error, pop off last directory added to stack
@ -85,17 +119,15 @@ export class DirectoryPickerComponent implements OnInit {
});
}
shareFolder(folderName: string, event: any) {
shareFolder(fullPath: string, event: any) {
event.preventDefault();
event.stopPropagation();
let fullPath = folderName;
if (this.routeStack.items.length > 0) {
const pathJoin = this.routeStack.items.join('/');
fullPath = pathJoin + ((pathJoin.endsWith('/') || pathJoin.endsWith('\\')) ? '' : '/') + folderName;
this.modal.close({success: true, folderPath: fullPath});
}
this.modal.close({success: true, folderPath: fullPath});
share() {
this.modal.close({success: true, folderPath: this.path});
}
close() {
@ -122,6 +154,9 @@ export class DirectoryPickerComponent implements OnInit {
}
const fullPath = this.routeStack.items.join('/');
this.path = fullPath;
this.loadChildren(fullPath);
}
}

View File

@ -10,4 +10,5 @@ export interface ServerSettings {
bookmarksDirectory: string;
emailServiceUrl: string;
convertBookmarkToWebP: boolean;
enableSwaggerUi: boolean;
}

View File

@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { NgbDropdownModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { ManageLibraryComponent } from './manage-library/manage-library.component';
import { ManageUsersComponent } from './manage-users/manage-users.component';
import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component';
@ -53,11 +53,12 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas
FormsModule,
NgbNavModule,
NgbTooltipModule,
NgbTypeaheadModule, // Directory Picker
NgbDropdownModule,
SharedModule,
PipeModule,
SidenavModule,
UserSettingsModule // API-key componet
UserSettingsModule, // API-key componet
],
providers: []
})

View File

@ -47,6 +47,15 @@
</div>
</div>
<div class="mb-3">
<label for="swagger-ui" class="form-label" aria-describedby="swaggerui-info">Expose Swagger UI</label>
<p class="accent" id="swaggerui-info">Allows Swagger UI to be exposed via swagger/ on your server. Authentication is not required, but a valid JWT token is. Requires a restart to take effect.</p>
<div class="form-check form-switch">
<input id="swagger-ui" type="checkbox" class="form-check-input" formControlName="enableSwaggerUi" role="switch">
<label for="swagger-ui" class="form-check-label">Enable Swagger UI</label>
</div>
</div>
<!-- TODO: Move this to Plugins tab once we build out some basic tables -->
<div class="mb-3">
<label for="opds" aria-describedby="opds-info" class="form-label">OPDS</label>

View File

@ -3,8 +3,7 @@ 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 { EmailTestResult, SettingsService } from '../settings.service';
import { SettingsService } from '../settings.service';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { ServerSettings } from '../_models/server-settings';
@ -21,7 +20,7 @@ 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 modalService: NgbModal) { }
ngOnInit(): void {
@ -43,6 +42,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required]));
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
this.settingsForm.addControl('enableSwaggerUi', new FormControl(this.serverSettings.enableSwaggerUi, [Validators.required]));
});
}
@ -57,6 +57,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl);
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
this.settingsForm.get('enableSwaggerUi')?.setValue(this.serverSettings.enableSwaggerUi);
}
async saveSettings() {
@ -92,29 +93,4 @@ export class ManageSettingsComponent implements OnInit {
}
});
}
// resetEmailServiceUrl() {
// this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
// this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
// this.resetForm();
// this.toastr.success('Email Service Reset');
// }, (err: any) => {
// console.error('error: ', err);
// });
// }
// testEmailServiceUrl() {
// this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => {
// if (result.successful) {
// this.toastr.success('Email Service Url validated');
// } else {
// this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
// }
// }, (err: any) => {
// console.error('error: ', err);
// });
// }
}