Implement EPUB page spread feature for desktop (#1267)

* Implement EPUB page spread feature for desktop

* Implement EPUB page spread feature for desktop
This commit is contained in:
Aditya Chandel
2025-10-05 10:28:16 -06:00
committed by GitHub
parent ba61885790
commit 23aa45b264
25 changed files with 396 additions and 213 deletions

View File

@@ -113,6 +113,7 @@ public class BookLoreUser {
private Float letterSpacing;
private Float lineHeight;
private String flow;
private String spread;
}
@Data

View File

@@ -10,6 +10,7 @@ public class EpubViewerPreferences {
private String theme;
private String font;
private String flow;
private String spread;
private Integer fontSize;
private Float letterSpacing;
private Float lineHeight;

View File

@@ -20,8 +20,8 @@ public class MetadataRefreshOptions {
private Boolean reviewBeforeApply;
@NotNull(message = "Field options cannot be null")
private FieldOptions fieldOptions;
@NotNull(message = "Skip fields cannot be null")
private SkipFields skipFields;
@NotNull(message = "Enabled fields cannot be null")
private EnabledFields enabledFields;
@Getter
@Setter
@@ -76,7 +76,7 @@ public class MetadataRefreshOptions {
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class SkipFields {
public static class EnabledFields {
private boolean title;
private boolean subtitle;
private boolean description;

View File

@@ -41,4 +41,7 @@ public class EpubViewerPreferencesEntity {
@Column(name = "flow")
private String flow;
@Column(name = "spread")
private String spread;
}

View File

@@ -193,6 +193,7 @@ public class BookService {
.fontSize(epubPref.getFontSize())
.theme(epubPref.getTheme())
.flow(epubPref.getFlow())
.spread(epubPref.getSpread())
.letterSpacing(epubPref.getLetterSpacing())
.lineHeight(epubPref.getLineHeight())
.build()));
@@ -272,6 +273,7 @@ public class BookService {
epubPrefs.setFontSize(epubSettings.getFontSize());
epubPrefs.setTheme(epubSettings.getTheme());
epubPrefs.setFlow(epubSettings.getFlow());
epubPrefs.setSpread(epubSettings.getSpread());
epubPrefs.setLetterSpacing(epubSettings.getLetterSpacing());
epubPrefs.setLineHeight(epubSettings.getLineHeight());
epubViewerPreferencesRepository.save(epubPrefs);

View File

@@ -148,35 +148,35 @@ public class SettingPersistenceHelper {
.tags(nullProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder()
.title(false)
.subtitle(false)
.description(false)
.authors(false)
.publisher(false)
.publishedDate(false)
.seriesName(false)
.seriesNumber(false)
.seriesTotal(false)
.isbn13(false)
.isbn10(false)
.language(false)
.categories(false)
.cover(false)
.pageCount(false)
.asin(false)
.goodreadsId(false)
.comicvineId(false)
.hardcoverId(false)
.googleId(false)
.amazonRating(false)
.amazonReviewCount(false)
.goodreadsRating(false)
.goodreadsReviewCount(false)
.hardcoverRating(false)
.hardcoverReviewCount(false)
.moods(false)
.tags(false)
MetadataRefreshOptions.EnabledFields enabledFields = MetadataRefreshOptions.EnabledFields.builder()
.title(true)
.subtitle(true)
.description(true)
.authors(true)
.publisher(true)
.publishedDate(true)
.seriesName(true)
.seriesNumber(true)
.seriesTotal(true)
.isbn13(true)
.isbn10(true)
.language(true)
.categories(true)
.cover(true)
.pageCount(true)
.asin(true)
.goodreadsId(true)
.comicvineId(true)
.hardcoverId(true)
.googleId(true)
.amazonRating(true)
.amazonReviewCount(true)
.goodreadsRating(true)
.goodreadsReviewCount(true)
.hardcoverRating(true)
.hardcoverReviewCount(true)
.moods(true)
.tags(true)
.build();
return MetadataRefreshOptions.builder()
@@ -185,7 +185,7 @@ public class SettingPersistenceHelper {
.mergeCategories(true)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.enabledFields(enabledFields)
.build();
}

View File

@@ -393,116 +393,116 @@ public class MetadataRefreshService {
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map<MetadataProvider, BookMetadata> metadataMap) {
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
MetadataRefreshOptions.SkipFields skipFields = refreshOptions.getSkipFields();
MetadataRefreshOptions.EnabledFields enabledFields = refreshOptions.getEnabledFields();
if (!skipFields.isTitle()) {
if (enabledFields.isTitle()) {
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
}
if (!skipFields.isSubtitle()) {
if (enabledFields.isSubtitle()) {
metadata.setSubtitle(resolveFieldAsString(metadataMap, fieldOptions.getSubtitle(), BookMetadata::getSubtitle));
}
if (!skipFields.isDescription()) {
if (enabledFields.isDescription()) {
metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
}
if (!skipFields.isAuthors()) {
if (enabledFields.isAuthors()) {
metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors));
}
if (!skipFields.isPublisher()) {
if (enabledFields.isPublisher()) {
metadata.setPublisher(resolveFieldAsString(metadataMap, fieldOptions.getPublisher(), BookMetadata::getPublisher));
}
if (!skipFields.isPublishedDate()) {
if (enabledFields.isPublishedDate()) {
metadata.setPublishedDate(resolveField(metadataMap, fieldOptions.getPublishedDate(), BookMetadata::getPublishedDate));
}
if (!skipFields.isSeriesName()) {
if (enabledFields.isSeriesName()) {
metadata.setSeriesName(resolveFieldAsString(metadataMap, fieldOptions.getSeriesName(), BookMetadata::getSeriesName));
}
if (!skipFields.isSeriesNumber()) {
if (enabledFields.isSeriesNumber()) {
metadata.setSeriesNumber(resolveField(metadataMap, fieldOptions.getSeriesNumber(), BookMetadata::getSeriesNumber));
}
if (!skipFields.isSeriesTotal()) {
if (enabledFields.isSeriesTotal()) {
metadata.setSeriesTotal(resolveFieldAsInteger(metadataMap, fieldOptions.getSeriesTotal(), BookMetadata::getSeriesTotal));
}
if (!skipFields.isIsbn13()) {
if (enabledFields.isIsbn13()) {
metadata.setIsbn13(resolveFieldAsString(metadataMap, fieldOptions.getIsbn13(), BookMetadata::getIsbn13));
}
if (!skipFields.isIsbn10()) {
if (enabledFields.isIsbn10()) {
metadata.setIsbn10(resolveFieldAsString(metadataMap, fieldOptions.getIsbn10(), BookMetadata::getIsbn10));
}
if (!skipFields.isLanguage()) {
if (enabledFields.isLanguage()) {
metadata.setLanguage(resolveFieldAsString(metadataMap, fieldOptions.getLanguage(), BookMetadata::getLanguage));
}
if (!skipFields.isPageCount()) {
if (enabledFields.isPageCount()) {
metadata.setPageCount(resolveFieldAsInteger(metadataMap, fieldOptions.getPageCount(), BookMetadata::getPageCount));
}
if (!skipFields.isCover()) {
if (enabledFields.isCover()) {
metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
}
if (!skipFields.isAmazonRating()) {
if (enabledFields.isAmazonRating()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAmazonRating(metadataMap.get(Amazon).getAmazonRating());
}
}
if (!skipFields.isAmazonReviewCount()) {
if (enabledFields.isAmazonReviewCount()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAmazonReviewCount(metadataMap.get(Amazon).getAmazonReviewCount());
}
}
if (!skipFields.isGoodreadsRating()) {
if (enabledFields.isGoodreadsRating()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsRating(metadataMap.get(GoodReads).getGoodreadsRating());
}
}
if (!skipFields.isGoodreadsReviewCount()) {
if (enabledFields.isGoodreadsReviewCount()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsReviewCount(metadataMap.get(GoodReads).getGoodreadsReviewCount());
}
}
if (!skipFields.isHardcoverRating()) {
if (enabledFields.isHardcoverRating()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverRating(metadataMap.get(Hardcover).getHardcoverRating());
}
}
if (!skipFields.isHardcoverReviewCount()) {
if (enabledFields.isHardcoverReviewCount()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverReviewCount(metadataMap.get(Hardcover).getHardcoverReviewCount());
}
}
if (!skipFields.isAsin()) {
if (enabledFields.isAsin()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAsin(metadataMap.get(Amazon).getAsin());
}
}
if (!skipFields.isGoodreadsId()) {
if (enabledFields.isGoodreadsId()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId());
}
}
if (!skipFields.isHardcoverId()) {
if (enabledFields.isHardcoverId()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId());
}
}
if (!skipFields.isGoogleId()) {
if (enabledFields.isGoogleId()) {
if (metadataMap.containsKey(Google)) {
metadata.setGoogleId(metadataMap.get(Google).getGoogleId());
}
}
if (!skipFields.isComicvineId()) {
if (enabledFields.isComicvineId()) {
if (metadataMap.containsKey(Comicvine)) {
metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
}
}
if (!skipFields.isMoods()) {
if (enabledFields.isMoods()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setMoods(metadataMap.get(Hardcover).getMoods());
}
}
if (!skipFields.isTags()) {
if (enabledFields.isTags()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setTags(metadataMap.get(Hardcover).getTags());
}
}
if (!skipFields.isCategories()) {
if (enabledFields.isCategories()) {
if (refreshOptions.isMergeCategories()) {
metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
} else {
@@ -596,4 +596,5 @@ public class MetadataRefreshService {
case BOOKS -> request.getBookIds();
};
}
}
}

View File

@@ -69,6 +69,7 @@ public class DefaultUserSettingsProvider {
.letterSpacing(null)
.lineHeight(null)
.flow("paginated")
.spread("double")
.build();
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE epub_viewer_preference
ADD COLUMN IF NOT EXISTS spread VARCHAR(20) DEFAULT 'double';

View File

@@ -82,6 +82,39 @@ class MetadataRefreshServiceTest {
setupTestEntities();
}
private MetadataRefreshOptions.EnabledFields allEnabledFields() {
return MetadataRefreshOptions.EnabledFields.builder()
.title(true)
.subtitle(true)
.description(true)
.authors(true)
.publisher(true)
.publishedDate(true)
.seriesName(true)
.seriesNumber(true)
.seriesTotal(true)
.isbn13(true)
.isbn10(true)
.language(true)
.categories(true)
.cover(true)
.pageCount(true)
.asin(true)
.goodreadsId(true)
.comicvineId(true)
.hardcoverId(true)
.googleId(true)
.amazonRating(true)
.amazonReviewCount(true)
.goodreadsRating(true)
.goodreadsReviewCount(true)
.hardcoverRating(true)
.hardcoverReviewCount(true)
.moods(true)
.tags(true)
.build();
}
private void setupDefaultOptions() {
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p3(MetadataProvider.Google)
@@ -116,14 +149,14 @@ class MetadataRefreshServiceTest {
.cover(coverProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
defaultOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.enabledFields(skipFields)
.build();
}
@@ -136,7 +169,7 @@ class MetadataRefreshServiceTest {
.title(titleProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
libraryOptions = MetadataRefreshOptions.builder()
.libraryId(1L)
@@ -144,7 +177,7 @@ class MetadataRefreshServiceTest {
.mergeCategories(true)
.reviewBeforeApply(true)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.enabledFields(skipFields)
.build();
}
@@ -160,7 +193,6 @@ class MetadataRefreshServiceTest {
testLibrary.setId(1L);
testLibrary.setName("Test Library");
// Create AuthorEntity for proper type compatibility
AuthorEntity authorEntity = new AuthorEntity();
authorEntity.setName("Test Author");
@@ -252,21 +284,20 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() {
// Given
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Hardcover)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(titleProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
MetadataRefreshOptions requestOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.enabledFields(skipFields)
.build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
@@ -322,14 +353,14 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException {
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
MetadataRefreshOptions reviewOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(true)
.fieldOptions(defaultOptions.getFieldOptions())
.skipFields(skipFields)
.enabledFields(skipFields)
.build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
@@ -522,14 +553,14 @@ class MetadataRefreshServiceTest {
.cover(coverProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
MetadataRefreshOptions mergeOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(true)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.enabledFields(skipFields)
.build();
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();

View File

@@ -109,6 +109,36 @@
</div>
</div>
@if (selectedFlow === 'paginated' && !isMobileDevice()) {
<p-divider></p-divider>
<div class="flex items-center gap-2">
<label id="spread-label" class="block font-semibold text-gray-200">Page Spead:</label>
<div class="flex gap-4" role="radiogroup" aria-labelledby="spread-label">
<div class="flex items-center gap-1.5">
<p-radioButton
name="spread"
[value]="'single'"
[(ngModel)]="selectedSpread"
(onClick)="changeSpreadMode()"
inputId="spread-single">
</p-radioButton>
<label for="spread-single" class="cursor-pointer select-none text-gray-200">Single</label>
</div>
<div class="flex items-center gap-1.5">
<p-radioButton
name="spread"
[value]="'double'"
[(ngModel)]="selectedSpread"
(onClick)="changeSpreadMode()"
inputId="spread-auto">
</p-radioButton>
<label for="spread-auto" class="cursor-pointer select-none text-gray-200">Double</label>
</div>
</div>
</div>
}
<p-divider></p-divider>
<div>

View File

@@ -178,3 +178,4 @@
::ng-deep .p-divider.p-divider-horizontal {
margin: 0.25rem 0 0.5rem 0 !important;
}

View File

@@ -49,6 +49,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
selectedFlow?: string = 'paginated';
selectedTheme?: string = 'white';
selectedFontType?: string | null = null;
selectedSpread?: string = 'double';
lineHeight?: number;
letterSpacing?: number;
@@ -115,6 +116,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
const resolvedTheme = settingScope === 'Global' ? globalSettings.theme : individualSetting?.theme;
const resolvedLineHeight = settingScope === 'Global' ? globalSettings.lineHeight : individualSetting?.lineHeight;
const resolvedLetterSpacing = settingScope === 'Global' ? globalSettings.letterSpacing : individualSetting?.letterSpacing;
const resolvedSpread = settingScope === 'Global' ? globalSettings.spread || 'double' : individualSetting?.spread || 'double';
if (resolvedTheme != null) this.selectedTheme = resolvedTheme;
if (resolvedFontFamily != null) this.selectedFontType = resolvedFontFamily;
@@ -122,12 +124,14 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
if (resolvedLineHeight != null) this.lineHeight = resolvedLineHeight;
if (resolvedLetterSpacing != null) this.letterSpacing = resolvedLetterSpacing;
if (resolvedFlow != null) this.selectedFlow = resolvedFlow;
if (resolvedSpread != null) this.selectedSpread = resolvedSpread;
this.rendition = this.book.renderTo(this.epubContainer.nativeElement, {
flow: this.selectedFlow ?? 'paginated',
manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default',
width: '100%',
height: '100%',
spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none',
allowScriptedContent: true,
});
@@ -193,11 +197,35 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default',
width: '100%',
height: '100%',
spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none',
allowScriptedContent: true,
});
this.rendition.themes.override('font-size', `${this.fontSize}%`);
this.applyCombinedTheme();
this.setupKeyListener();
this.setupTouchListeners();
this.rendition.display(cfi || undefined);
this.updateViewerSetting();
}
changeSpreadMode(): void {
if (!this.rendition || !this.book || this.selectedFlow === 'scrolled' || this.isMobileDevice()) return;
const cfi = this.rendition.currentLocation()?.start?.cfi;
this.rendition.destroy();
this.rendition = this.book.renderTo(this.epubContainer.nativeElement, {
flow: this.selectedFlow,
manager: 'default',
width: '100%',
height: '100%',
spread: this.selectedSpread === 'single' ? 'none' : this.selectedSpread,
allowScriptedContent: true,
});
this.rendition.themes.override('font-size', `${this.fontSize}%`);
this.applyCombinedTheme();
this.setupKeyListener();
this.setupTouchListeners();
this.rendition.display(cfi || undefined);
@@ -255,6 +283,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
if (this.selectedFontType) epubSettings.font = this.selectedFontType;
if (this.fontSize) epubSettings.fontSize = this.fontSize;
if (this.selectedFlow) epubSettings.flow = this.selectedFlow;
if (this.selectedSpread === 'single' || this.selectedSpread === 'double') epubSettings.spread = this.selectedSpread;
if (this.lineHeight) epubSettings.lineHeight = this.lineHeight;
if (this.letterSpacing) epubSettings.letterSpacing = this.letterSpacing;
@@ -287,19 +316,21 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
private setupTouchListeners(): void {
if (!this.isMobileDevice() || this.selectedFlow === 'scrolled') return;
this.rendition.on('rendered', () => {
const container = this.epubContainer.nativeElement;
container.removeEventListener('touchstart', this.onTouchStart.bind(this));
container.removeEventListener('touchend', this.onTouchEnd.bind(this));
container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true});
container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true});
setTimeout(() => {
const iframe = this.epubContainer.nativeElement.querySelector('iframe');
if (iframe && iframe.contentDocument) {
const iframeDoc = iframe.contentDocument;
iframeDoc.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true});
iframeDoc.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true});
}
});
const container = this.epubContainer.nativeElement;
container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true});
container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true});
}, 500);
}
onTouchStart(event: TouchEvent): void {
@@ -328,7 +359,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
}
}
private isMobileDevice(): boolean {
public isMobileDevice(): boolean {
return window.innerWidth <= 768;
}

View File

@@ -187,6 +187,7 @@ export interface EpubViewerSetting {
flow: string;
lineHeight: number;
letterSpacing: number;
spread: string;
}
export interface CbxViewerSetting {

View File

@@ -2,8 +2,8 @@
<table class="min-w-full table-auto border-collapse custom-table">
<thead>
<tr>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">Skip</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">Metadata Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 70px; width: 70px;">Enabled</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 127px; width: 127px;">Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
4th Priority
<i class="pi pi-question-circle ml-1 text-xs"
@@ -30,8 +30,8 @@
</th>
</tr>
<tr>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-sm text-zinc-400 italic">Set All:</td>
<td class="px-4 py-2" style="max-width: 70px; width: 70px;"></td>
<td class="px-4 py-2 text-sm text-zinc-400 italic" style="max-width: 127px; width: 127px;">Set All:</td>
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
(ngModelChange)="setBulkProvider('p4', $event)"
@@ -64,38 +64,37 @@
</thead>
<tbody>
@for (field of nonProviderSpecificFields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers"
[class.opacity-50]="skipFields[field]">
<td class="px-4 py-1.5">
<p-checkbox [(ngModel)]="skipFields[field]" [binary]="true"
pTooltip="Skip this field during metadata fetch"
<tr [hidden]="field === 'cover' && !refreshCovers" [class.opacity-50]="!enabledFields[field]">
<td class="px-4 py-1.5" style="max-width: 70px; width: 70px;">
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
</td>
<td class="px-4 py-1.5 text-zinc-200">{{ formatLabel(field) }}</td>
<td class="px-4 py-1.5 text-zinc-200" style="max-width: 127px; width: 127px;">{{ formatLabel(field) }}</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
[disabled]="skipFields[field]"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
[disabled]="skipFields[field]"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
[disabled]="skipFields[field]"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
[disabled]="skipFields[field]"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
@@ -107,12 +106,12 @@
<div class="space-y-4">
<h3 class="text-lg font-semibold text-zinc-300">Provider-Specific Fields</h3>
<p class="text-sm text-zinc-400">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to skip fetching these fields entirely.</p>
<p class="text-sm text-zinc-400">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to enable/disable fetching these fields.</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (field of providerSpecificFields; track field) {
<div class="flex items-center space-x-3 p-3 border border-zinc-600 rounded-lg">
<p-checkbox [(ngModel)]="skipFields[field]" [binary]="true"
pTooltip="Skip this field during metadata fetch"
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
<span class="text-sm text-zinc-300">{{ formatLabel(field) }}</span>
</div>

View File

@@ -50,7 +50,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
reviewBeforeApply: boolean = false;
fieldOptions: FieldOptions = this.initializeFieldOptions();
skipFields: Record<keyof FieldOptions, boolean> = this.initializeSkipFields();
enabledFields: Record<keyof FieldOptions, boolean> = this.initializeEnabledFields();
bulkP1: string | null = null;
bulkP2: string | null = null;
@@ -74,9 +74,9 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}, {} as FieldOptions);
}
private initializeSkipFields(): Record<keyof FieldOptions, boolean> {
private initializeEnabledFields(): Record<keyof FieldOptions, boolean> {
return this.fields.reduce((acc, field) => {
acc[field] = false;
acc[field] = true;
return acc;
}, {} as Record<keyof FieldOptions, boolean>);
}
@@ -97,10 +97,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}
this.fieldOptions = backendFieldOptions;
if (this.currentMetadataOptions.skipFields) {
this.skipFields = {...this.skipFields, ...this.currentMetadataOptions.skipFields};
if (this.currentMetadataOptions.enabledFields) {
this.enabledFields = {...this.enabledFields, ...this.currentMetadataOptions.enabledFields};
} else {
this.skipFields = this.initializeSkipFields();
this.enabledFields = this.initializeEnabledFields();
}
}
}
@@ -120,7 +120,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
submit() {
const allFieldsHaveProvider = Object.entries(this.fieldOptions).every(([field, opt]) =>
this.skipFields[field as keyof FieldOptions] ||
!this.enabledFields[field as keyof FieldOptions] ||
this.isProviderSpecificField(field as keyof FieldOptions) ||
opt.p1 !== null || opt.p2 !== null || opt.p3 !== null || opt.p4 !== null
);
@@ -134,7 +134,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
mergeCategories: this.mergeCategories,
reviewBeforeApply: this.reviewBeforeApply,
fieldOptions: this.fieldOptions,
skipFields: this.skipFields
enabledFields: this.enabledFields
};
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
@@ -146,7 +146,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'At least one provider (P1P4) must be selected for each non-skipped book field.',
detail: 'At least one provider (P1P4) must be selected for each enabled book field.',
life: 5000
});
}
@@ -158,7 +158,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
const value = provider === 'Clear All' ? null : provider;
for (const field of this.nonProviderSpecificFields) {
if (!this.skipFields[field]) {
if (this.enabledFields[field]) {
this.fieldOptions[field][priority] = value;
}
}
@@ -189,7 +189,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
p4: null
};
}
this.skipFields = this.initializeSkipFields();
this.enabledFields = this.initializeEnabledFields();
// Reset bulk selectors
this.bulkP1 = null;

View File

@@ -4,7 +4,7 @@ export interface MetadataRefreshOptions {
mergeCategories: boolean;
reviewBeforeApply: boolean;
fieldOptions?: FieldOptions;
skipFields?: Record<keyof FieldOptions, boolean>;
enabledFields?: Record<keyof FieldOptions, boolean>;
}
export interface FieldProvider {

View File

@@ -22,6 +22,7 @@
The system checks your 1st priority provider first - if that provider doesn't have the specific field (like description or author),
it automatically moves to your 2nd priority, then 3rd, and finally 4th. Leave a priority empty to skip it entirely.
For example, if Amazon (1st) has no description but Google Books (2nd) does, Google's description will be used.
Use the Enable checkboxes to completely disable fetching for specific fields - disabled fields will be skipped entirely regardless of provider settings.
</p>
</div>
@@ -42,6 +43,7 @@
</h3>
<p class="section-description">
Override the default priority settings for specific libraries. For example, you might prefer Amazon for fiction but Google Books for technical books. Each library can have its own provider priority order while falling back to defaults for unspecified fields.
You can also enable or disable specific metadata fields per library - useful if certain libraries don't need specific data types like descriptions or covers.
</p>
</div>

View File

@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
min-width: 100px;
}
.radio-group {

View File

@@ -21,18 +21,19 @@ export class CbxReaderPreferencesComponent {
private readonly readerPreferencesService = inject(ReaderPreferencesService);
readonly cbxSpreads = [
{ name: 'Even', key: 'EVEN' },
{ name: 'Odd', key: 'ODD' }
{name: 'Even', key: 'EVEN'},
{name: 'Odd', key: 'ODD'}
];
readonly cbxViewModes = [
{ name: 'Single Page', key: 'SINGLE_PAGE' },
{ name: 'Two Page', key: 'TWO_PAGE' },
{name: 'Single Page', key: 'SINGLE_PAGE'},
{name: 'Two Page', key: 'TWO_PAGE'},
];
get selectedCbxSpread(): CbxPageSpread {
return this.userSettings.cbxReaderSetting.pageSpread;
}
set selectedCbxSpread(value: CbxPageSpread) {
this.userSettings.cbxReaderSetting.pageSpread = value;
this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageSpread'], value);
@@ -41,6 +42,7 @@ export class CbxReaderPreferencesComponent {
get selectedCbxViewMode(): CbxPageViewMode {
return this.userSettings.cbxReaderSetting.pageViewMode;
}
set selectedCbxViewMode(value: CbxPageViewMode) {
this.userSettings.cbxReaderSetting.pageViewMode = value;
this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageViewMode'], value);

View File

@@ -1,77 +1,115 @@
<div class="epub-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Theme</label>
<p-select
size="small"
[options]="themes"
[(ngModel)]="selectedTheme"
optionLabel="name"
optionValue="key"
placeholder="Select a Theme"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Choose the visual theme for EPUB reading experience.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font</label>
<p-select
size="small"
[options]="fonts"
[(ngModel)]="selectedFont"
optionLabel="name"
optionValue="key"
placeholder="Select a Font"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Select the font family for text display.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Flow</label>
<p-select
size="small"
[options]="flowOptions"
[(ngModel)]="selectedFlow"
optionLabel="name"
optionValue="key"
placeholder="Select a Flow"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Configure text flow and reading direction.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font Size</label>
<div class="font-size-controls">
<p-button icon="pi pi-minus" size="small" rounded (click)="decreaseFontSize()"></p-button>
<span class="font-size-value">{{ fontSize }}%</span>
<p-button icon="pi pi-plus" size="small" rounded (click)="increaseFontSize()"></p-button>
<div class="p-4">
<div class="epub-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Theme</label>
<div class="radio-group">
@for (theme of themes; track theme) {
<div class="radio-option">
<p-radiobutton
[inputId]="'theme-' + theme.key"
name="theme"
[value]="theme.key"
[(ngModel)]="selectedTheme">
</p-radiobutton>
<label [for]="'theme-' + theme.key">{{ theme.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose the visual theme for EPUB reading experience.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font</label>
<div class="radio-group">
@for (font of fonts; track font) {
<div class="radio-option">
<p-radiobutton
[inputId]="'font-' + font.key"
name="font"
[value]="font.key"
[(ngModel)]="selectedFont">
</p-radiobutton>
<label [for]="'font-' + font.key">{{ font.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Select the font family for text display.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Flow</label>
<div class="radio-group">
@for (flow of flowOptions; track flow) {
<div class="radio-option">
<p-radiobutton
[inputId]="'flow-' + flow.key"
name="flow"
[value]="flow.key"
[(ngModel)]="selectedFlow">
</p-radiobutton>
<label [for]="'flow-' + flow.key">{{ flow.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Configure text flow and reading direction.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Spread</label>
<div class="radio-group">
@for (spread of spreadOptions; track spread) {
<div class="radio-option">
<p-radiobutton
[inputId]="'spread-' + spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedSpread">
</p-radiobutton>
<label [for]="'spread-' + spread.key">{{ spread.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose between single page or double page spread view.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font Size</label>
<div class="font-size-controls">
<p-button icon="pi pi-minus" size="small" rounded (click)="decreaseFontSize()"></p-button>
<span class="font-size-value">{{ fontSize }}%</span>
<p-button icon="pi pi-plus" size="small" rounded (click)="increaseFontSize()"></p-button>
</div>
</div>
<p class="setting-description">
Adjust the text size for comfortable reading.
</p>
</div>
<p class="setting-description">
Adjust the text size for comfortable reading.
</p>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
.epub-preferences-container {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.5rem;
}
.setting-item {
@@ -46,26 +46,13 @@
min-width: 100px;
}
p-select {
flex: 1;
min-width: 200px;
max-width: 300px;
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
min-width: 180px;
}
}
.font-size-controls {
display: flex;
align-items: center;
gap: 0.75rem;
.font-size-value {
min-width: 3rem;
text-align: center;
font-weight: 500;
color: var(--p-text-color);
flex-direction: column;
gap: 0.75rem;
}
}
}
@@ -77,3 +64,38 @@
margin: 0;
}
}
.radio-group {
display: flex;
gap: 1.5rem;
@media (max-width: 768px) {
flex-direction: column;
gap: 0.75rem;
}
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
label {
font-size: 0.875rem;
color: var(--p-text-color);
cursor: pointer;
}
}
.font-size-controls {
display: flex;
align-items: center;
gap: 1rem;
.font-size-value {
min-width: 3rem;
text-align: center;
font-weight: 500;
color: var(--p-text-color);
}
}

View File

@@ -1,6 +1,6 @@
import {Component, inject, Input} from '@angular/core';
import {Button} from 'primeng/button';
import {Select} from 'primeng/select';
import {RadioButton} from 'primeng/radiobutton';
import {FormsModule} from '@angular/forms';
import {ReaderPreferencesService} from '../reader-preferences-service';
import {UserSettings} from '../../user-management/user.service';
@@ -9,7 +9,7 @@ import {UserSettings} from '../../user-management/user.service';
selector: 'app-epub-reader-preferences-component',
imports: [
Button,
Select,
RadioButton,
FormsModule
],
templateUrl: './epub-reader-preferences-component.html',
@@ -31,13 +31,16 @@ export class EpubReaderPreferencesComponent {
];
readonly flowOptions = [
{name: 'Book Default', key: null},
{name: 'Paginated', key: 'paginated'},
{name: 'Scrolled', key: 'scrolled'}
];
readonly spreadOptions = [
{name: 'Single Page', key: 'single'},
{name: 'Double Page', key: 'double'}
];
readonly themes = [
{name: 'Book Default', key: null},
{name: 'White', key: 'white'},
{name: 'Black', key: 'black'},
{name: 'Grey', key: 'grey'},
@@ -77,6 +80,17 @@ export class EpubReaderPreferencesComponent {
this.readerPreferencesService.updatePreference(['epubReaderSetting', 'flow'], value);
}
get selectedSpread(): string | null {
return this.userSettings.epubReaderSetting.spread;
}
set selectedSpread(value: string | null) {
if (typeof value === "string") {
this.userSettings.epubReaderSetting.spread = value;
}
this.readerPreferencesService.updatePreference(['epubReaderSetting', 'spread'], value);
}
get fontSize(): number {
return this.userSettings.epubReaderSetting.fontSize;
}

View File

@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
min-width: 100px;
}
.radio-group {

View File

@@ -55,6 +55,7 @@ export interface EpubReaderSetting {
font: string;
fontSize: number;
flow: string;
spread: string;
lineHeight: number;
margin: number;
letterSpacing: number;