Merge pull request #2400 from booklore-app/develop

Merge develop into master for the release
This commit is contained in:
ACX
2026-01-22 13:27:54 -07:00
committed by GitHub
22 changed files with 114 additions and 85 deletions

View File

@@ -1,21 +0,0 @@
name: Draft Release
on:
push:
branches:
- master
jobs:
draft-release:
name: Update Draft Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- name: Draft Release Notes
uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,4 +12,5 @@ public class KoreaderUser {
private String password;
private String passwordMD5;
private boolean syncEnabled;
private boolean syncWithBookloreReader;
}

View File

@@ -25,24 +25,23 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
""")
List<ReadingSessionCountDto> findSessionCountsByUserAndYear(@Param("userId") Long userId, @Param("year") int year);
@Query("""
SELECT
b.id as bookId,
COALESCE(b.metadata.title,
(SELECT bf.fileName FROM BookFileEntity bf WHERE bf.book.id = b.id ORDER BY bf.id ASC LIMIT 1),
'Unknown Book') as bookTitle,
rs.bookType as bookFileType,
MIN(rs.startTime) as startDate,
MAX(rs.endTime) as endDate,
COUNT(rs) as totalSessions,
SUM(rs.durationSeconds) as totalDurationSeconds
FROM ReadingSessionEntity rs
JOIN rs.book b
WHERE rs.user.id = :userId
AND rs.startTime >= :startOfWeek AND rs.startTime < :endOfWeek
GROUP BY b.id, b.metadata.title, rs.bookType, CAST(rs.startTime AS LocalDate)
ORDER BY MIN(rs.startTime)
""")
@Query("""
SELECT
b.id as bookId,
COALESCE(b.metadata.title,
(SELECT bf.fileName FROM BookFileEntity bf WHERE bf.book.id = b.id ORDER BY bf.id ASC LIMIT 1),
'Unknown Book') as bookTitle,
rs.bookType as bookFileType,
rs.startTime as startDate,
rs.endTime as endDate,
1L as totalSessions,
rs.durationSeconds as totalDurationSeconds
FROM ReadingSessionEntity rs
JOIN rs.book b
WHERE rs.user.id = :userId
AND rs.startTime >= :startOfWeek AND rs.startTime < :endOfWeek
ORDER BY rs.startTime
""")
List<ReadingSessionTimelineDto> findSessionTimelineByUserAndWeek(
@Param("userId") Long userId,
@Param("startOfWeek") Instant startOfWeek,

View File

@@ -4,14 +4,18 @@ import com.adityachandel.booklore.model.dto.KoreaderUser;
import com.adityachandel.booklore.service.koreader.KoreaderUserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
class KoreaderUserControllerTest {
@@ -26,7 +30,7 @@ class KoreaderUserControllerTest {
@BeforeEach
void setUp() {
try (AutoCloseable mocks = MockitoAnnotations.openMocks(this)) {
user = new KoreaderUser(1L, "testuser", "pass", "md5", true);
user = new KoreaderUser(1L, "testuser", "pass", "md5", true, true);
} catch (Exception e) {
throw new RuntimeException(e);
}

View File

@@ -53,7 +53,7 @@ class KoreaderUserServiceTest {
entity.setBookLoreUser(ownerEntity);
entity.setUsername("kvUser");
dto = new KoreaderUser(10L, "kvUser", null, null, false);
dto = new KoreaderUser(10L, "kvUser", null, null, false, true);
when(koreaderUserMapper.toDto(any(KoreaderUserEntity.class))).thenReturn(dto);
}
@@ -69,7 +69,7 @@ class KoreaderUserServiceTest {
when(koreaderUserMapper.toDto(any(KoreaderUserEntity.class))).thenAnswer(invocation -> {
KoreaderUserEntity u = invocation.getArgument(0);
return new KoreaderUser(u.getId(), u.getUsername(), u.getPassword(), u.getPasswordMD5(), u.isSyncEnabled());
return new KoreaderUser(u.getId(), u.getUsername(), u.getPassword(), u.getPasswordMD5(), u.isSyncEnabled(), u.isSyncWithBookloreReader());
});
KoreaderUser result = service.upsertUser("userA", "passA");

View File

@@ -36,7 +36,7 @@
<tr>
<td>{{ formatDate(session.startTime) }}</td>
<td>{{ formatDate(session.endTime) }}</td>
<td>{{ formatDuration(session.durationSeconds) }}</td>
<td>{{ getActualDuration(session) }}</td>
<td>
@if (session.startProgress !== null && session.startProgress !== undefined && session.endProgress !== null && session.endProgress !== undefined) {
<span>{{ session.startProgress }}% → {{ session.endProgress }}%</span>

View File

@@ -69,6 +69,24 @@ export class BookReadingSessionsComponent implements OnInit, OnChanges {
return `${secs}s`;
}
calculateActualDuration(session: ReadingSessionResponse): number {
const startTime = new Date(session.startTime).getTime();
const endTime = new Date(session.endTime).getTime();
return Math.floor((endTime - startTime) / 1000);
}
getActualDuration(session: ReadingSessionResponse): string {
const actualDuration = this.calculateActualDuration(session);
const storedDuration = session.durationSeconds;
if (Math.abs(actualDuration - storedDuration) > 1) {
// Discrepancy detected - show both values
return `${this.formatDuration(actualDuration)}`;
}
return this.formatDuration(actualDuration);
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}

View File

@@ -1,3 +1,5 @@
@use 'sass:color';
// Variables
$footer-bg: #1a1a1a;
$text-primary: rgba(255, 255, 255, 0.95);
@@ -95,7 +97,7 @@ $transition-fast: 150ms ease;
}
&:hover:not(:disabled) {
background: darken($active-color, 10%);
background: color.adjust($active-color, $lightness: -10%);
}
&:disabled {
@@ -289,7 +291,7 @@ $transition-fast: 150ms ease;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: darken($active-color, 10%);
background: color.adjust($active-color, $lightness: -10%);
}
&:disabled {

View File

@@ -1,3 +1,5 @@
@use 'sass:color';
// Variables
$panel-bg: #1a1a1a;
$text-primary: rgba(255, 255, 255, 0.95);
@@ -134,7 +136,7 @@ $transition-normal: 200ms ease;
background: rgba(255, 255, 255, 0.2);
&.active {
background: lighten($active-color, 5%);
background: color.adjust($active-color, $lightness: 5%);
}
}
}

View File

@@ -77,23 +77,21 @@
</p-toggleswitch>
<label class="setting-label">Hardcover</label>
</div>
@if (hardcoverEnabled) {
<div class="setting-options">
<div class="config-field">
<div class="config-label-row">
<label class="config-label">API Token</label>
<app-external-doc-link docType="hardcover"></app-external-doc-link>
</div>
<input
type="text"
pInputText
placeholder="Enter Hardcover API token"
[(ngModel)]="hardcoverToken"
(ngModelChange)="onTokenChange($event)"
class="config-input"/>
<div class="setting-options">
<div class="config-field">
<div class="config-label-row">
<label class="config-label">API Token</label>
<app-external-doc-link docType="hardcover"></app-external-doc-link>
</div>
<input
type="text"
pInputText
placeholder="Enter Hardcover API token"
[(ngModel)]="hardcoverToken"
(ngModelChange)="onTokenChange($event)"
class="config-input"/>
</div>
}
</div>
</div>
<div class="setting-item">

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.author-universe-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.book-formats-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.language-chart-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.metadata-score-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.page-count-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.publication-timeline-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.publication-trend-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.reading-journey-container {
width: 100%;

View File

@@ -1,4 +1,4 @@
@import '../../../_chart-shared';
@use '../../../_chart-shared';
.top-items-container {
width: 100%;

View File

@@ -145,8 +145,8 @@ export class ReadingSessionTimelineComponent implements OnInit {
response.forEach((item) => {
const startTime = new Date(item.startDate);
const duration = item.totalDurationSeconds / 60;
const endTime = new Date(startTime.getTime() + item.totalDurationSeconds * 1000);
const endTime = item.endDate ? new Date(item.endDate) : new Date(startTime.getTime() + item.totalDurationSeconds * 1000);
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
sessions.push({
startTime,

View File

@@ -1,4 +1,4 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi, afterEach} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {EnvironmentInjector, runInInjectionContext} from '@angular/core';
import {HttpClient, HttpEventType, HttpHeaders, HttpResponse} from '@angular/common/http';
@@ -38,6 +38,12 @@ describe('FileDownloadService', () => {
const injector = TestBed.inject(EnvironmentInjector);
service = runInInjectionContext(injector, () => TestBed.inject(FileDownloadService));
vi.useFakeTimers();
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
});
it('should trigger download and handle progress and completion', () => {
@@ -58,15 +64,23 @@ describe('FileDownloadService', () => {
// Mock DOM for download
const appendChild = vi.fn();
const removeChild = vi.fn();
const link = {
setAttribute: vi.fn(),
click: vi.fn(),
style: {},
href: '',
download: '',
parentNode: {
removeChild: removeChild
}
};
(globalThis as any).document = {
createElement: vi.fn().mockReturnValue({
setAttribute: vi.fn(),
click: vi.fn(),
style: {},
href: '',
download: ''
}),
body: {appendChild, removeChild}
createElement: vi.fn().mockReturnValue(link),
body: {
appendChild: appendChild,
removeChild: removeChild,
contains: vi.fn().mockReturnValue(true)
}
};
(globalThis as any).window = {
URL: {
@@ -134,6 +148,12 @@ describe('FileDownloadService - API Contract Tests', () => {
const injector = TestBed.inject(EnvironmentInjector);
service = runInInjectionContext(injector, () => TestBed.inject(FileDownloadService));
vi.useFakeTimers();
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
});
describe('API contract', () => {

View File

@@ -94,7 +94,13 @@ export class FileDownloadService {
link.click();
setTimeout(() => {
document.body.removeChild(link);
try {
if (link && link.parentNode) {
link.parentNode.removeChild(link);
}
} catch (e) {
// Ignore errors during cleanup, may occur if DOM is not available
}
window.URL.revokeObjectURL(objectUrl);
}, 100);
}