mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Merge pull request #2400 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
21
.github/workflows/draft-release.yml
vendored
21
.github/workflows/draft-release.yml
vendored
@@ -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 }}
|
||||
@@ -12,4 +12,5 @@ public class KoreaderUser {
|
||||
private String password;
|
||||
private String passwordMD5;
|
||||
private boolean syncEnabled;
|
||||
private boolean syncWithBookloreReader;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.author-universe-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.book-formats-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.language-chart-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.metadata-score-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.page-count-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.publication-timeline-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.publication-trend-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.reading-journey-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../_chart-shared';
|
||||
@use '../../../_chart-shared';
|
||||
|
||||
.top-items-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user