mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
5541e1acdc
Migrate the snapshot and snapshot books tables to be per-device instead of per-user. This resolves a sync race condition where only the first registered Kobo device for a user receives the library delta, leaving the others empty. Closes #314
1686 lines
63 KiB
TypeScript
1686 lines
63 KiB
TypeScript
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
import { SQL, and, asc, count, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm';
|
|
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
|
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
|
|
import type { ContentFilterRules, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
|
|
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
|
|
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
|
import { SeriesIdentityService } from '../../common/services/series-identity.service';
|
|
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
|
import { BookQueryBuilder } from './book-query-builder.service';
|
|
import { DB } from '../../db';
|
|
import * as schema from '../../db/schema';
|
|
import {
|
|
authors,
|
|
bookAuthors,
|
|
bookCommunityRatings,
|
|
bookFiles,
|
|
bookGenres,
|
|
bookMetadata,
|
|
bookNarrators,
|
|
bookSeries,
|
|
bookSeriesMemberships,
|
|
books,
|
|
bookTags,
|
|
collectionBooks,
|
|
collections,
|
|
genres,
|
|
koboDevices,
|
|
koboLibrarySnapshots,
|
|
koboReadingStates,
|
|
koboSnapshotBooks,
|
|
koboSyncSettings,
|
|
libraries,
|
|
narrators,
|
|
audiobookProgress,
|
|
readingProgress,
|
|
userBookRatings,
|
|
tags,
|
|
userBookStatus,
|
|
} from '../../db/schema';
|
|
|
|
type Db = NodePgDatabase<typeof schema>;
|
|
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
|
type MetadataUpdateExecutor = Pick<Db, 'delete' | 'insert' | 'select' | 'update'>;
|
|
type MetadataReadExecutor = Pick<Db, 'select'>;
|
|
type JsonObj = Record<string, unknown>;
|
|
|
|
type CollapsedRawRow = {
|
|
id: number;
|
|
status: string;
|
|
primary_file_id: number | null;
|
|
folder_path: string;
|
|
added_at: string;
|
|
updated_at: string;
|
|
title: string | null;
|
|
series_id: number | null;
|
|
series_name: string | null;
|
|
series_index: number | null;
|
|
published_date: string | null;
|
|
published_year: number | null;
|
|
language: string | null;
|
|
rating: number | null;
|
|
metadata_score: number | null;
|
|
cover_source: string | null;
|
|
locked_fields: string[] | null;
|
|
subtitle: string | null;
|
|
isbn13: string | null;
|
|
hardcover_id: string | null;
|
|
hardcover_edition_id: string | null;
|
|
publisher: string | null;
|
|
page_count: number | null;
|
|
sort_title: string | null;
|
|
sort_added_at: string | null;
|
|
book_count: string | null;
|
|
read_count: string | null;
|
|
cover_book_ids: number[] | null;
|
|
cover_updated_at_by_book_id: JsonObj | null;
|
|
first_volume_book_id: number | null;
|
|
latest_volume_book_id: number | null;
|
|
first_unread_book_id: number | null;
|
|
total_count: string;
|
|
};
|
|
type PatternMetadataRow = {
|
|
bookId: number;
|
|
title: string | null;
|
|
subtitle: string | null;
|
|
publisher: string | null;
|
|
publishedDate: string | null;
|
|
publishedYear: number | null;
|
|
language: string | null;
|
|
seriesId: number | null;
|
|
seriesName: string | null;
|
|
seriesIndex: number | null;
|
|
isbn13: string | null;
|
|
authors: string[];
|
|
};
|
|
|
|
function parseDateByBookId(value: JsonObj | null | undefined): Record<number, Date | null> {
|
|
const result: Record<number, Date | null> = {};
|
|
for (const [rawBookId, rawValue] of Object.entries(value ?? {})) {
|
|
const bookId = Number(rawBookId);
|
|
if (!Number.isInteger(bookId) || bookId <= 0) continue;
|
|
if (rawValue instanceof Date) {
|
|
result[bookId] = Number.isNaN(rawValue.getTime()) ? null : rawValue;
|
|
continue;
|
|
}
|
|
if (typeof rawValue === 'string' || typeof rawValue === 'number') {
|
|
const parsed = new Date(rawValue);
|
|
result[bookId] = Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
continue;
|
|
}
|
|
result[bookId] = null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const PROGRESS_EPSILON = 0.0001;
|
|
|
|
// Shared between the collapsed listing query and the collapsed jump-buckets
|
|
// query: both must pick the exact same series representative or bucket indexes
|
|
// drift from listing offsets. Guarded by the jump-buckets invariant e2e test.
|
|
const COLLAPSE_GROUP_KEY_SQL = `base.library_id, COALESCE(base.series_id::text, 'book_' || base.id::text)`;
|
|
const COLLAPSE_REPRESENTATIVE_PICK_SQL = `${COLLAPSE_GROUP_KEY_SQL},
|
|
base.series_index ASC NULLS LAST,
|
|
base.added_at ASC,
|
|
base.id ASC`;
|
|
|
|
type JumpBucketRawRow = {
|
|
bucket: string;
|
|
item_index: number | string;
|
|
total: number | string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class BookRepository {
|
|
constructor(
|
|
@Inject(DB) private readonly db: Db,
|
|
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
|
|
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
|
|
) {}
|
|
|
|
private visibleWhere(where: SQL | undefined): SQL {
|
|
return where ? and(where, ne(books.status, 'processing'))! : ne(books.status, 'processing');
|
|
}
|
|
|
|
async withTransaction<T>(callback: (tx: DbTransaction) => Promise<T>): Promise<T> {
|
|
return this.db.transaction((tx) => callback(tx));
|
|
}
|
|
|
|
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number; userId: number }) {
|
|
const { where, orderBy, limit, offset, userId } = opts;
|
|
const visibleWhere = this.visibleWhere(where);
|
|
|
|
const rows = await this.db
|
|
.select({
|
|
id: books.id,
|
|
status: books.status,
|
|
primaryFileId: books.primaryFileId,
|
|
folderPath: books.folderPath,
|
|
addedAt: books.addedAt,
|
|
updatedAt: books.updatedAt,
|
|
title: bookMetadata.title,
|
|
seriesId: bookMetadata.seriesId,
|
|
seriesName: bookMetadata.seriesName,
|
|
seriesIndex: bookMetadata.seriesIndex,
|
|
publishedDate: bookMetadata.publishedDate,
|
|
publishedYear: bookMetadata.publishedYear,
|
|
language: bookMetadata.language,
|
|
rating: userBookRatings.rating,
|
|
coverSource: bookMetadata.coverSource,
|
|
lockedFields: bookMetadata.lockedFields,
|
|
subtitle: bookMetadata.subtitle,
|
|
publisher: bookMetadata.publisher,
|
|
pageCount: bookMetadata.pageCount,
|
|
isbn13: bookMetadata.isbn13,
|
|
hardcoverId: bookMetadata.hardcoverId,
|
|
hardcoverEditionId: bookMetadata.hardcoverEditionId,
|
|
metadataScore: bookMetadata.metadataScore,
|
|
_total: sql<number>`count(*) over()`.as('_total'),
|
|
})
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
|
|
.where(visibleWhere)
|
|
.orderBy(...orderBy)
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
const total = rows.length > 0 ? rows[0]._total : await this.countWhere(visibleWhere);
|
|
|
|
const bookRefs = rows.map((r) => ({ id: r.id, primaryFileId: r.primaryFileId ?? null }));
|
|
const enrichment = await this.enrichBookIds(bookRefs, userId);
|
|
|
|
return { rows, ...enrichment, total: Number(total) };
|
|
}
|
|
|
|
private async enrichBookIds(bookRefs: Array<{ id: number; primaryFileId: number | null }>, userId: number) {
|
|
const bookIds = bookRefs.map((book) => book.id);
|
|
const primaryFileIds = bookRefs.map((book) => book.primaryFileId).filter((id): id is number => id != null);
|
|
|
|
if (bookIds.length === 0) {
|
|
return {
|
|
authorRows: [] as { bookId: number; name: string }[],
|
|
fileRows: [] as { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null }[],
|
|
genreRows: [] as { bookId: number; name: string }[],
|
|
tagRows: [] as { bookId: number; name: string }[],
|
|
progressRows: [] as { bookFileId: number; percentage: number }[],
|
|
statusRows: [] as {
|
|
bookId: number;
|
|
status: string;
|
|
source: string;
|
|
startedAt: Date | null;
|
|
finishedAt: Date | null;
|
|
updatedAt: Date;
|
|
}[],
|
|
narratorRows: [] as { bookId: number; name: string }[],
|
|
seriesMembershipRows: [] as {
|
|
bookId: number;
|
|
seriesId: number;
|
|
seriesName: string;
|
|
seriesIndex: number | null;
|
|
displayOrder: number;
|
|
}[],
|
|
};
|
|
}
|
|
|
|
const [authorRows, fileRows, genreRows, tagRows, narratorRows, seriesMembershipRows, statusRows, fileProgressRows, audiobookProgressRows] =
|
|
await Promise.all([
|
|
this.db
|
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(inArray(bookAuthors.bookId, bookIds))
|
|
.orderBy(bookAuthors.displayOrder),
|
|
this.db
|
|
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role, sizeBytes: bookFiles.sizeBytes })
|
|
.from(bookFiles)
|
|
.where(inArray(bookFiles.bookId, bookIds)),
|
|
this.db
|
|
.select({ bookId: bookGenres.bookId, name: genres.name })
|
|
.from(bookGenres)
|
|
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
|
|
.where(inArray(bookGenres.bookId, bookIds)),
|
|
this.db
|
|
.select({ bookId: bookTags.bookId, name: tags.name })
|
|
.from(bookTags)
|
|
.innerJoin(tags, eq(tags.id, bookTags.tagId))
|
|
.where(inArray(bookTags.bookId, bookIds)),
|
|
this.db
|
|
.select({ bookId: bookNarrators.bookId, name: narrators.name })
|
|
.from(bookNarrators)
|
|
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
|
.where(inArray(bookNarrators.bookId, bookIds))
|
|
.orderBy(bookNarrators.displayOrder),
|
|
this.db
|
|
.select({
|
|
bookId: bookSeriesMemberships.bookId,
|
|
seriesId: bookSeriesMemberships.seriesId,
|
|
seriesName: bookSeries.name,
|
|
seriesIndex: bookSeriesMemberships.seriesIndex,
|
|
displayOrder: bookSeriesMemberships.displayOrder,
|
|
})
|
|
.from(bookSeriesMemberships)
|
|
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
|
.where(inArray(bookSeriesMemberships.bookId, bookIds))
|
|
.orderBy(asc(bookSeriesMemberships.bookId), asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
|
|
this.db
|
|
.select({
|
|
bookId: userBookStatus.bookId,
|
|
status: userBookStatus.status,
|
|
source: userBookStatus.source,
|
|
startedAt: userBookStatus.startedAt,
|
|
finishedAt: userBookStatus.finishedAt,
|
|
updatedAt: userBookStatus.updatedAt,
|
|
})
|
|
.from(userBookStatus)
|
|
.where(and(eq(userBookStatus.userId, userId), inArray(userBookStatus.bookId, bookIds))),
|
|
primaryFileIds.length > 0
|
|
? this.db
|
|
.select({
|
|
bookFileId: readingProgress.bookFileId,
|
|
percentage: readingProgress.percentage,
|
|
updatedAt: readingProgress.updatedAt,
|
|
})
|
|
.from(readingProgress)
|
|
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
|
|
: Promise.resolve([] as { bookFileId: number; percentage: number; updatedAt: Date }[]),
|
|
this.db
|
|
.select({
|
|
bookId: audiobookProgress.bookId,
|
|
percentage: audiobookProgress.percentage,
|
|
updatedAt: audiobookProgress.updatedAt,
|
|
})
|
|
.from(audiobookProgress)
|
|
.where(and(eq(audiobookProgress.userId, userId), inArray(audiobookProgress.bookId, bookIds))),
|
|
]);
|
|
|
|
const fileProgressById = new Map(fileProgressRows.map((row) => [row.bookFileId, row]));
|
|
const audiobookProgressByBookId = new Map(audiobookProgressRows.map((row) => [row.bookId, row]));
|
|
const progressRows = bookRefs.flatMap((book) => {
|
|
if (book.primaryFileId == null) return [];
|
|
|
|
const fileProgress = fileProgressById.get(book.primaryFileId);
|
|
const audioProgress = audiobookProgressByBookId.get(book.id);
|
|
if (!fileProgress && !audioProgress) return [];
|
|
|
|
const mergedPercentage =
|
|
fileProgress && audioProgress
|
|
? fileProgress.updatedAt >= audioProgress.updatedAt
|
|
? fileProgress.percentage
|
|
: audioProgress.percentage
|
|
: (fileProgress?.percentage ?? audioProgress?.percentage ?? null);
|
|
|
|
return [{ bookFileId: book.primaryFileId, percentage: mergedPercentage }];
|
|
});
|
|
|
|
return { authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows };
|
|
}
|
|
|
|
async findCardsByBookIds(bookIds: number[], userId: number) {
|
|
if (bookIds.length === 0) {
|
|
return {
|
|
rows: [],
|
|
authorRows: [],
|
|
fileRows: [],
|
|
genreRows: [],
|
|
tagRows: [],
|
|
progressRows: [],
|
|
statusRows: [],
|
|
narratorRows: [],
|
|
seriesMembershipRows: [],
|
|
total: 0,
|
|
};
|
|
}
|
|
|
|
return this.findCards({
|
|
where: inArray(books.id, bookIds),
|
|
orderBy: [],
|
|
limit: bookIds.length,
|
|
offset: 0,
|
|
userId,
|
|
});
|
|
}
|
|
|
|
async findCardsCollapsed(opts: { where: SQL | undefined; sort: SortSpec[]; limit: number; offset: number; userId: number }): Promise<{
|
|
rows: Array<{
|
|
id: number;
|
|
status: string;
|
|
primaryFileId: number | null;
|
|
folderPath: string;
|
|
addedAt: Date;
|
|
updatedAt: Date;
|
|
title: string | null;
|
|
seriesName: string | null;
|
|
seriesId: number | null;
|
|
seriesIndex: number | null;
|
|
publishedDate: string | null;
|
|
publishedYear: number | null;
|
|
language: string | null;
|
|
rating: number | null;
|
|
metadataScore: number | null;
|
|
coverSource: string | null;
|
|
lockedFields: string[] | null;
|
|
subtitle: string | null;
|
|
publisher: string | null;
|
|
pageCount: number | null;
|
|
isbn13: string | null;
|
|
hardcoverId: string | null;
|
|
hardcoverEditionId: string | null;
|
|
bookCount: number | null;
|
|
readCount: number | null;
|
|
coverBookIds: number[] | null;
|
|
coverUpdatedAtByBookId: Record<number, Date | null> | null;
|
|
seriesLatestAddedAt: Date | null;
|
|
firstVolumeBookId: number | null;
|
|
latestVolumeBookId: number | null;
|
|
firstUnreadBookId: number | null;
|
|
}>;
|
|
authorRows: { bookId: number; name: string }[];
|
|
fileRows: { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null }[];
|
|
genreRows: { bookId: number; name: string }[];
|
|
tagRows: { bookId: number; name: string }[];
|
|
progressRows: { bookFileId: number; percentage: number | null }[];
|
|
statusRows: {
|
|
bookId: number;
|
|
status: string;
|
|
source: string;
|
|
startedAt: Date | null;
|
|
finishedAt: Date | null;
|
|
updatedAt: Date;
|
|
}[];
|
|
narratorRows: { bookId: number; name: string }[];
|
|
seriesMembershipRows: { bookId: number; seriesId: number; seriesName: string; seriesIndex: number | null; displayOrder: number }[];
|
|
total: number;
|
|
}> {
|
|
const { where, sort, limit, offset, userId } = opts;
|
|
const whereFragment = this.visibleWhere(where);
|
|
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId);
|
|
|
|
const result = await this.db.execute<CollapsedRawRow>(sql`
|
|
WITH base_rows AS (
|
|
SELECT
|
|
books.id,
|
|
books.library_id,
|
|
books.status,
|
|
books.primary_file_id,
|
|
books.primary_author_sort_name,
|
|
books.folder_path,
|
|
books.added_at,
|
|
books.updated_at,
|
|
book_metadata.title,
|
|
book_metadata.series_id,
|
|
book_metadata.series_name,
|
|
book_metadata.series_index,
|
|
book_metadata.published_date,
|
|
book_metadata.published_year,
|
|
book_metadata.language,
|
|
book_metadata.cover_source,
|
|
book_metadata.locked_fields,
|
|
book_metadata.publisher,
|
|
book_metadata.page_count,
|
|
book_metadata.subtitle,
|
|
book_metadata.isbn13,
|
|
book_metadata.hardcover_id,
|
|
book_metadata.hardcover_edition_id,
|
|
book_metadata.metadata_score,
|
|
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
|
|
FROM books
|
|
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
|
|
WHERE ${whereFragment}
|
|
),
|
|
series_agg AS (
|
|
SELECT
|
|
base.series_id,
|
|
base.library_id,
|
|
COUNT(*) AS book_count,
|
|
SUM(CASE WHEN user_book_status.status = 'read' THEN 1 ELSE 0 END) AS read_count,
|
|
MAX(base.added_at) AS latest_added_at
|
|
FROM base_rows base
|
|
LEFT JOIN user_book_status ON user_book_status.book_id = base.id AND user_book_status.user_id = ${userId}
|
|
WHERE base.series_id IS NOT NULL
|
|
GROUP BY base.series_id, base.library_id
|
|
),
|
|
series_cover_candidates AS (
|
|
SELECT
|
|
base.series_id,
|
|
base.library_id,
|
|
base.id,
|
|
base.series_index,
|
|
base.added_at,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY base.series_id, base.library_id
|
|
ORDER BY base.series_index ASC NULLS LAST, base.added_at ASC, base.id ASC
|
|
) AS rn
|
|
FROM base_rows base
|
|
WHERE base.series_id IS NOT NULL
|
|
),
|
|
series_covers AS (
|
|
SELECT
|
|
scc.series_id,
|
|
scc.library_id,
|
|
COALESCE(
|
|
ARRAY_AGG(scc.id ORDER BY scc.series_index ASC NULLS LAST, scc.added_at ASC, scc.id ASC) FILTER (WHERE scc.rn <= 4),
|
|
ARRAY[]::int[]
|
|
) AS cover_book_ids
|
|
FROM series_cover_candidates scc
|
|
GROUP BY scc.series_id, scc.library_id
|
|
),
|
|
series_first_volume AS (
|
|
SELECT scc.series_id, scc.library_id, scc.id AS first_volume_book_id
|
|
FROM series_cover_candidates scc
|
|
WHERE scc.rn = 1
|
|
),
|
|
series_latest_volume AS (
|
|
SELECT slv.series_id, slv.library_id, slv.id AS latest_volume_book_id
|
|
FROM (
|
|
SELECT
|
|
base.series_id,
|
|
base.library_id,
|
|
base.id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY base.series_id, base.library_id
|
|
ORDER BY base.series_index DESC NULLS LAST, base.added_at DESC, base.id DESC
|
|
) AS rn
|
|
FROM base_rows base
|
|
WHERE base.series_id IS NOT NULL
|
|
) slv
|
|
WHERE slv.rn = 1
|
|
),
|
|
series_first_unread AS (
|
|
SELECT sfu.series_id, sfu.library_id, sfu.id AS first_unread_book_id
|
|
FROM (
|
|
SELECT
|
|
base.series_id,
|
|
base.library_id,
|
|
base.id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY base.series_id, base.library_id
|
|
ORDER BY base.series_index ASC NULLS LAST, base.added_at ASC, base.id ASC
|
|
) AS rn
|
|
FROM base_rows base
|
|
LEFT JOIN user_book_status ubs ON ubs.book_id = base.id AND ubs.user_id = ${userId}
|
|
WHERE base.series_id IS NOT NULL
|
|
AND ubs.status IS DISTINCT FROM 'read'
|
|
) sfu
|
|
WHERE sfu.rn = 1
|
|
),
|
|
series_cover_version_ids AS (
|
|
SELECT scc.series_id, scc.library_id, scc.id
|
|
FROM series_cover_candidates scc
|
|
WHERE scc.rn <= 4
|
|
UNION
|
|
SELECT sfv.series_id, sfv.library_id, sfv.first_volume_book_id AS id
|
|
FROM series_first_volume sfv
|
|
UNION
|
|
SELECT slv.series_id, slv.library_id, slv.latest_volume_book_id AS id
|
|
FROM series_latest_volume slv
|
|
UNION
|
|
SELECT sfu.series_id, sfu.library_id, sfu.first_unread_book_id AS id
|
|
FROM series_first_unread sfu
|
|
),
|
|
series_cover_versions AS (
|
|
SELECT
|
|
scvi.series_id,
|
|
scvi.library_id,
|
|
COALESCE(JSONB_OBJECT_AGG(scvi.id::text, base.updated_at), '{}'::jsonb) AS cover_updated_at_by_book_id
|
|
FROM series_cover_version_ids scvi
|
|
INNER JOIN base_rows base
|
|
ON base.id = scvi.id
|
|
AND base.series_id = scvi.series_id
|
|
AND base.library_id = scvi.library_id
|
|
GROUP BY scvi.series_id, scvi.library_id
|
|
),
|
|
representatives AS (
|
|
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
|
|
base.id,
|
|
base.status,
|
|
base.primary_file_id,
|
|
base.folder_path,
|
|
base.added_at,
|
|
base.updated_at,
|
|
base.title,
|
|
base.series_id,
|
|
base.series_name,
|
|
base.series_index,
|
|
base.published_date,
|
|
base.published_year,
|
|
base.language,
|
|
ubr.rating,
|
|
base.cover_source,
|
|
base.locked_fields,
|
|
base.publisher,
|
|
base.page_count,
|
|
base.subtitle,
|
|
base.isbn13,
|
|
base.hardcover_id,
|
|
base.hardcover_edition_id,
|
|
base.metadata_score,
|
|
base.primary_author_sort_name AS author_sort_name,
|
|
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
|
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
|
|
sa.book_count,
|
|
sa.read_count,
|
|
sc.cover_book_ids,
|
|
scv.cover_updated_at_by_book_id,
|
|
sfv.first_volume_book_id,
|
|
slv2.latest_volume_book_id,
|
|
sfu2.first_unread_book_id
|
|
FROM base_rows base
|
|
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${userId}
|
|
LEFT JOIN series_agg sa
|
|
ON sa.series_id = base.series_id
|
|
AND sa.library_id = base.library_id
|
|
LEFT JOIN series_covers sc
|
|
ON sc.series_id = sa.series_id
|
|
AND sc.library_id = sa.library_id
|
|
LEFT JOIN series_cover_versions scv
|
|
ON scv.series_id = sa.series_id
|
|
AND scv.library_id = sa.library_id
|
|
LEFT JOIN series_first_volume sfv
|
|
ON sfv.series_id = base.series_id
|
|
AND sfv.library_id = base.library_id
|
|
LEFT JOIN series_latest_volume slv2
|
|
ON slv2.series_id = base.series_id
|
|
AND slv2.library_id = base.library_id
|
|
LEFT JOIN series_first_unread sfu2
|
|
ON sfu2.series_id = base.series_id
|
|
AND sfu2.library_id = base.library_id
|
|
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
|
|
)
|
|
SELECT r.*,
|
|
COUNT(*) OVER () AS total_count
|
|
FROM representatives r
|
|
ORDER BY ${sql.raw(orderBy)}
|
|
LIMIT ${limit} OFFSET ${offset}
|
|
`);
|
|
|
|
const rawRows = result.rows as CollapsedRawRow[];
|
|
const total = rawRows.length > 0 ? Number(rawRows[0].total_count) : 0;
|
|
|
|
const mappedRows = rawRows.map((r) => ({
|
|
id: r.id,
|
|
status: r.status,
|
|
primaryFileId: r.primary_file_id,
|
|
folderPath: r.folder_path,
|
|
addedAt: new Date(r.added_at),
|
|
updatedAt: new Date(r.updated_at),
|
|
title: r.title,
|
|
seriesId: r.series_id,
|
|
seriesName: r.series_name,
|
|
seriesIndex: r.series_index,
|
|
publishedDate: r.published_date,
|
|
publishedYear: r.published_year,
|
|
language: r.language,
|
|
rating: r.rating,
|
|
metadataScore: r.metadata_score !== null ? Number(r.metadata_score) : null,
|
|
coverSource: r.cover_source,
|
|
lockedFields: r.locked_fields,
|
|
subtitle: r.subtitle,
|
|
publisher: r.publisher,
|
|
pageCount: r.page_count !== null ? Number(r.page_count) : null,
|
|
isbn13: r.isbn13,
|
|
hardcoverId: r.hardcover_id,
|
|
hardcoverEditionId: r.hardcover_edition_id,
|
|
bookCount: r.book_count !== null ? Number(r.book_count) : null,
|
|
readCount: r.read_count !== null ? Number(r.read_count) : null,
|
|
coverBookIds: r.cover_book_ids,
|
|
coverUpdatedAtByBookId: parseDateByBookId(r.cover_updated_at_by_book_id),
|
|
seriesLatestAddedAt: r.sort_added_at ? new Date(r.sort_added_at) : null,
|
|
firstVolumeBookId: r.first_volume_book_id ?? null,
|
|
latestVolumeBookId: r.latest_volume_book_id ?? null,
|
|
firstUnreadBookId: r.first_unread_book_id ?? null,
|
|
}));
|
|
|
|
const bookRefs = mappedRows.map((row) => ({ id: row.id, primaryFileId: row.primaryFileId ?? null }));
|
|
const enrichment = await this.enrichBookIds(bookRefs, userId);
|
|
|
|
return { rows: mappedRows, ...enrichment, total };
|
|
}
|
|
|
|
async findJumpBuckets(opts: { where: SQL | undefined; bucketExpr: SQL; orderBy: SQL[] }): Promise<JumpBucketsResponse> {
|
|
const visibleWhere = this.visibleWhere(opts.where);
|
|
const result = await this.db.execute<JumpBucketRawRow>(sql`
|
|
WITH ordered AS (
|
|
SELECT
|
|
${opts.bucketExpr} AS bucket,
|
|
(ROW_NUMBER() OVER (ORDER BY ${sql.join(opts.orderBy, sql`, `)}) - 1) AS item_index
|
|
FROM ${books}
|
|
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
|
|
WHERE ${visibleWhere}
|
|
)
|
|
SELECT bucket, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
|
|
FROM ordered
|
|
WHERE bucket IS NOT NULL
|
|
GROUP BY bucket
|
|
ORDER BY min(item_index)
|
|
`);
|
|
|
|
return this.mapJumpBucketRows(result.rows);
|
|
}
|
|
|
|
// The representatives CTE mirrors findCardsCollapsed's representative
|
|
// selection (same group key, same pick order) but only projects the columns
|
|
// buildCollapseOrderBy can reference as aliases or via r.* correlated
|
|
// subqueries.
|
|
async findJumpBucketsCollapsed(opts: { where: SQL | undefined; bucketExpr: SQL; sort: SortSpec[]; userId: number }): Promise<JumpBucketsResponse> {
|
|
const whereFragment = this.visibleWhere(opts.where);
|
|
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId);
|
|
const result = await this.db.execute<JumpBucketRawRow>(sql`
|
|
WITH base_rows AS (
|
|
SELECT
|
|
books.id,
|
|
books.library_id,
|
|
books.primary_file_id,
|
|
books.primary_author_sort_name,
|
|
books.added_at,
|
|
books.updated_at,
|
|
book_metadata.title,
|
|
book_metadata.series_id,
|
|
book_metadata.series_name,
|
|
book_metadata.series_index,
|
|
book_metadata.published_date,
|
|
book_metadata.published_year,
|
|
book_metadata.publisher,
|
|
book_metadata.page_count,
|
|
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
|
|
FROM books
|
|
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
|
|
WHERE ${whereFragment}
|
|
),
|
|
series_latest AS (
|
|
SELECT
|
|
base.series_id,
|
|
base.library_id,
|
|
MAX(base.added_at) AS latest_added_at
|
|
FROM base_rows base
|
|
WHERE base.series_id IS NOT NULL
|
|
GROUP BY base.series_id, base.library_id
|
|
),
|
|
representatives AS (
|
|
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
|
|
base.id,
|
|
base.primary_file_id,
|
|
base.updated_at,
|
|
base.series_index,
|
|
base.published_date,
|
|
base.published_year,
|
|
base.publisher,
|
|
base.page_count,
|
|
ubr.rating,
|
|
base.primary_author_sort_name AS author_sort_name,
|
|
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
|
|
COALESCE(sl.latest_added_at, base.added_at) AS sort_added_at
|
|
FROM base_rows base
|
|
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${opts.userId}
|
|
LEFT JOIN series_latest sl
|
|
ON sl.series_id = base.series_id
|
|
AND sl.library_id = base.library_id
|
|
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
|
|
),
|
|
ordered AS (
|
|
SELECT
|
|
${opts.bucketExpr} AS bucket,
|
|
(ROW_NUMBER() OVER (ORDER BY ${sql.raw(orderBy)}) - 1) AS item_index
|
|
FROM representatives r
|
|
)
|
|
SELECT bucket, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
|
|
FROM ordered
|
|
WHERE bucket IS NOT NULL
|
|
GROUP BY bucket
|
|
ORDER BY min(item_index)
|
|
`);
|
|
|
|
return this.mapJumpBucketRows(result.rows);
|
|
}
|
|
|
|
private mapJumpBucketRows(rows: JumpBucketRawRow[]): JumpBucketsResponse {
|
|
return {
|
|
buckets: rows.map((row) => ({
|
|
key: row.bucket,
|
|
label: row.bucket,
|
|
index: Number(row.item_index),
|
|
})),
|
|
total: rows.length > 0 ? Number(rows[0].total) : 0,
|
|
};
|
|
}
|
|
|
|
async findById(id: number) {
|
|
const [book] = await this.db
|
|
.select()
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.leftJoin(libraries, eq(libraries.id, books.libraryId))
|
|
.where(eq(books.id, id))
|
|
.limit(1);
|
|
|
|
if (!book) return null;
|
|
|
|
const [authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows] = await Promise.all([
|
|
this.db
|
|
.select({ id: authors.id, name: authors.name, sortName: authors.sortName })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(eq(bookAuthors.bookId, id))
|
|
.orderBy(bookAuthors.displayOrder),
|
|
this.db.select({ name: genres.name }).from(bookGenres).innerJoin(genres, eq(genres.id, bookGenres.genreId)).where(eq(bookGenres.bookId, id)),
|
|
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)),
|
|
this.db
|
|
.select({
|
|
id: bookFiles.id,
|
|
format: bookFiles.format,
|
|
role: bookFiles.role,
|
|
sizeBytes: bookFiles.sizeBytes,
|
|
absolutePath: bookFiles.absolutePath,
|
|
createdAt: bookFiles.createdAt,
|
|
durationSeconds: bookFiles.durationSeconds,
|
|
})
|
|
.from(bookFiles)
|
|
.where(eq(bookFiles.bookId, id))
|
|
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id)),
|
|
this.db
|
|
.select({ id: narrators.id, name: narrators.name, sortName: narrators.sortName, displayOrder: bookNarrators.displayOrder })
|
|
.from(bookNarrators)
|
|
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
|
.where(eq(bookNarrators.bookId, id))
|
|
.orderBy(bookNarrators.displayOrder),
|
|
this.db
|
|
.select({
|
|
seriesId: bookSeriesMemberships.seriesId,
|
|
seriesName: bookSeries.name,
|
|
seriesIndex: bookSeriesMemberships.seriesIndex,
|
|
displayOrder: bookSeriesMemberships.displayOrder,
|
|
})
|
|
.from(bookSeriesMemberships)
|
|
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
|
.where(eq(bookSeriesMemberships.bookId, id))
|
|
.orderBy(asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
|
|
this.db
|
|
.select({
|
|
provider: bookCommunityRatings.provider,
|
|
rating: bookCommunityRatings.rating,
|
|
ratingCount: bookCommunityRatings.ratingCount,
|
|
updatedAt: bookCommunityRatings.updatedAt,
|
|
})
|
|
.from(bookCommunityRatings)
|
|
.where(eq(bookCommunityRatings.bookId, id))
|
|
.orderBy(asc(bookCommunityRatings.provider)),
|
|
]);
|
|
|
|
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows };
|
|
}
|
|
|
|
async findRatingByBookAndUser(bookId: number, userId: number): Promise<number | null> {
|
|
const [row] = await this.db
|
|
.select({ rating: userBookRatings.rating })
|
|
.from(userBookRatings)
|
|
.where(and(eq(userBookRatings.bookId, bookId), eq(userBookRatings.userId, userId)))
|
|
.limit(1);
|
|
return row?.rating ?? null;
|
|
}
|
|
|
|
async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> {
|
|
return this.db
|
|
.select({ id: collections.id, name: collections.name })
|
|
.from(collectionBooks)
|
|
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId)))
|
|
.where(eq(collectionBooks.bookId, bookId))
|
|
.orderBy(collections.name);
|
|
}
|
|
|
|
async findLibraryIdByBookId(bookId: number): Promise<number | null> {
|
|
const [row] = await this.db.select({ libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
|
|
return row?.libraryId ?? null;
|
|
}
|
|
|
|
async checkBookPassesContentFilters(bookId: number, contentFilters: ContentFilterRules): Promise<boolean> {
|
|
const filterClauses = buildContentFilterClauses(contentFilters, this.db);
|
|
if (filterClauses.length === 0) return true;
|
|
|
|
const [row] = await this.db
|
|
.select({ id: books.id })
|
|
.from(books)
|
|
.where(and(eq(books.id, bookId), ...filterClauses))
|
|
.limit(1);
|
|
return !!row;
|
|
}
|
|
|
|
async findFileById(fileId: number) {
|
|
const [file] = await this.db
|
|
.select({
|
|
id: bookFiles.id,
|
|
absolutePath: bookFiles.absolutePath,
|
|
format: bookFiles.format,
|
|
role: bookFiles.role,
|
|
bookId: bookFiles.bookId,
|
|
libraryId: books.libraryId,
|
|
fileHash: bookFiles.fileHash,
|
|
sizeBytes: bookFiles.sizeBytes,
|
|
durationSeconds: bookFiles.durationSeconds,
|
|
})
|
|
.from(bookFiles)
|
|
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
|
.where(eq(bookFiles.id, fileId))
|
|
.limit(1);
|
|
return file ?? null;
|
|
}
|
|
|
|
async deleteBookFile(fileId: number): Promise<void> {
|
|
await this.db.delete(bookFiles).where(eq(bookFiles.id, fileId));
|
|
}
|
|
|
|
async updateBookFile(fileId: number, data: { format?: string | null; role?: string; absolutePath?: string; sizeBytes?: number }): Promise<void> {
|
|
await this.db
|
|
.update(bookFiles)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(bookFiles.id, fileId));
|
|
}
|
|
|
|
async updateBookPrimaryFile(bookId: number, primaryFileId: number | null): Promise<void> {
|
|
await this.db.update(books).set({ primaryFileId, updatedAt: new Date() }).where(eq(books.id, bookId));
|
|
}
|
|
|
|
async findFilesForBook(bookId: number) {
|
|
return this.db
|
|
.select({
|
|
id: bookFiles.id,
|
|
role: bookFiles.role,
|
|
})
|
|
.from(bookFiles)
|
|
.where(eq(bookFiles.bookId, bookId));
|
|
}
|
|
|
|
async findBookBase(bookId: number) {
|
|
const [row] = await this.db.select().from(books).where(eq(books.id, bookId)).limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
async findProgress(userId: number, fileId: number) {
|
|
const [row] = await this.db
|
|
.select()
|
|
.from(readingProgress)
|
|
.where(and(eq(readingProgress.bookFileId, fileId), eq(readingProgress.userId, userId)))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
async findProgressByBook(userId: number, bookId: number) {
|
|
return this.db
|
|
.select({
|
|
fileId: bookFiles.id,
|
|
cfi: readingProgress.cfi,
|
|
pageNumber: readingProgress.pageNumber,
|
|
percentage: readingProgress.percentage,
|
|
koboLocationSource: readingProgress.koboLocationSource,
|
|
koboLocationType: readingProgress.koboLocationType,
|
|
koboLocationValue: readingProgress.koboLocationValue,
|
|
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
|
|
koreaderProgress: readingProgress.koreaderProgress,
|
|
updatedAt: readingProgress.updatedAt,
|
|
})
|
|
.from(bookFiles)
|
|
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
|
|
.where(eq(bookFiles.bookId, bookId))
|
|
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id));
|
|
}
|
|
|
|
async findProgressByBooks(userId: number, bookIds: number[]) {
|
|
if (bookIds.length === 0) return [];
|
|
return this.db
|
|
.select({
|
|
bookId: bookFiles.bookId,
|
|
fileId: bookFiles.id,
|
|
percentage: readingProgress.percentage,
|
|
koreaderProgress: readingProgress.koreaderProgress,
|
|
updatedAt: readingProgress.updatedAt,
|
|
})
|
|
.from(bookFiles)
|
|
.innerJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
|
|
.where(inArray(bookFiles.bookId, bookIds));
|
|
}
|
|
|
|
async findKoboReadingState(userId: number, bookId: number) {
|
|
const [row] = await this.db
|
|
.select({
|
|
createdAtKobo: koboReadingStates.createdAtKobo,
|
|
lastModifiedKobo: koboReadingStates.lastModifiedKobo,
|
|
priorityTimestamp: koboReadingStates.priorityTimestamp,
|
|
currentBookmark: koboReadingStates.currentBookmark,
|
|
statistics: koboReadingStates.statistics,
|
|
statusInfo: koboReadingStates.statusInfo,
|
|
updatedAt: koboReadingStates.updatedAt,
|
|
})
|
|
.from(koboReadingStates)
|
|
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
async findKoboSnapshotStates(userId: number, bookId: number) {
|
|
return this.db
|
|
.select({
|
|
deviceId: koboDevices.id,
|
|
deviceName: koboDevices.name,
|
|
snapshotId: koboLibrarySnapshots.id,
|
|
snapshotUpdatedAt: koboLibrarySnapshots.updatedAt,
|
|
synced: koboSnapshotBooks.synced,
|
|
pendingDelete: koboSnapshotBooks.pendingDelete,
|
|
isNew: koboSnapshotBooks.isNew,
|
|
removedByDevice: koboSnapshotBooks.removedByDevice,
|
|
fileHash: koboSnapshotBooks.fileHash,
|
|
metadataHash: koboSnapshotBooks.metadataHash,
|
|
})
|
|
.from(koboLibrarySnapshots)
|
|
.innerJoin(koboDevices, and(eq(koboDevices.id, koboLibrarySnapshots.deviceId), eq(koboDevices.userId, userId)))
|
|
.leftJoin(koboSnapshotBooks, and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)))
|
|
.where(eq(koboLibrarySnapshots.userId, userId))
|
|
.orderBy(asc(koboDevices.createdAt), asc(koboDevices.id));
|
|
}
|
|
|
|
async findKoboSyncCollectionNamesForBook(userId: number, bookId: number): Promise<string[]> {
|
|
const rows = await this.db
|
|
.select({ name: collections.name })
|
|
.from(collectionBooks)
|
|
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)))
|
|
.where(eq(collectionBooks.bookId, bookId));
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
async searchAcrossLibraries(libraryIds: number[], q: string, limit: number, contentFilters?: ContentFilterRules) {
|
|
if (libraryIds.length === 0) return [];
|
|
|
|
const pattern = '%' + q + '%';
|
|
|
|
const matchedAuthors = this.db
|
|
.selectDistinct({ bookId: bookAuthors.bookId })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(sql`${authors.name} ILIKE ${pattern}`)
|
|
.as('matched_authors');
|
|
|
|
const matchedSeries = this.db
|
|
.selectDistinct({ bookId: bookSeriesMemberships.bookId })
|
|
.from(bookSeriesMemberships)
|
|
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
|
.where(sql`${bookSeries.name} ILIKE ${pattern}`)
|
|
.as('matched_series');
|
|
|
|
const contentFilterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
|
|
|
const rows = await this.db
|
|
.select({
|
|
id: books.id,
|
|
title: bookMetadata.title,
|
|
seriesId: bookMetadata.seriesId,
|
|
seriesName: bookMetadata.seriesName,
|
|
libraryId: books.libraryId,
|
|
libraryName: libraries.name,
|
|
updatedAt: books.updatedAt,
|
|
})
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
|
.leftJoin(matchedAuthors, eq(matchedAuthors.bookId, books.id))
|
|
.leftJoin(matchedSeries, eq(matchedSeries.bookId, books.id))
|
|
.where(
|
|
and(
|
|
inArray(books.libraryId, libraryIds),
|
|
ne(books.status, 'processing'),
|
|
or(
|
|
sql`${bookMetadata.title} ILIKE ${pattern}`,
|
|
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
|
|
isNotNull(matchedAuthors.bookId),
|
|
isNotNull(matchedSeries.bookId),
|
|
),
|
|
...contentFilterClauses,
|
|
),
|
|
)
|
|
.orderBy(bookMetadata.title)
|
|
.limit(limit);
|
|
|
|
const bookIds = rows.map((r) => r.id);
|
|
|
|
const authorRows =
|
|
bookIds.length > 0
|
|
? await this.db
|
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(inArray(bookAuthors.bookId, bookIds))
|
|
.orderBy(bookAuthors.displayOrder)
|
|
: [];
|
|
|
|
const authorsByBook = new Map<number, string[]>();
|
|
for (const row of authorRows) {
|
|
const list = authorsByBook.get(row.bookId) ?? [];
|
|
list.push(row.name);
|
|
authorsByBook.set(row.bookId, list);
|
|
}
|
|
|
|
const formatRows =
|
|
bookIds.length > 0
|
|
? await this.db
|
|
.select({ bookId: bookFiles.bookId, format: bookFiles.format })
|
|
.from(bookFiles)
|
|
.where(and(inArray(bookFiles.bookId, bookIds), inArray(bookFiles.format, [...SUPPORTED_BOOK_FORMATS])))
|
|
: [];
|
|
|
|
const formatsByBook = new Map<number, string[]>();
|
|
for (const row of formatRows) {
|
|
if (row.format) {
|
|
const list = formatsByBook.get(row.bookId) ?? [];
|
|
if (!list.includes(row.format)) list.push(row.format);
|
|
formatsByBook.set(row.bookId, list);
|
|
}
|
|
}
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
seriesName: r.seriesName,
|
|
authors: authorsByBook.get(r.id) ?? [],
|
|
libraryId: r.libraryId,
|
|
libraryName: r.libraryName,
|
|
updatedAt: r.updatedAt?.toISOString() ?? null,
|
|
formats: formatsByBook.get(r.id) ?? [],
|
|
}));
|
|
}
|
|
|
|
async countWhere(where: SQL | undefined): Promise<number> {
|
|
const [{ total }] = await this.db
|
|
.select({ total: count() })
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.where(this.visibleWhere(where));
|
|
return Number(total);
|
|
}
|
|
|
|
async findLibraryIdsByBookIds(bookIds: number[]): Promise<{ id: number; libraryId: number }[]> {
|
|
if (bookIds.length === 0) return [];
|
|
return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds));
|
|
}
|
|
|
|
async findRecommendationTitlesByBookIds(
|
|
bookIds: number[],
|
|
): Promise<
|
|
{ id: number; title: string | null; updatedAt: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean }[]
|
|
> {
|
|
if (bookIds.length === 0) return [];
|
|
|
|
const [rows, authorRows] = await Promise.all([
|
|
this.db
|
|
.select({
|
|
id: books.id,
|
|
title: bookMetadata.title,
|
|
updatedAt: books.updatedAt,
|
|
coverSource: bookMetadata.coverSource,
|
|
primaryFormat: bookFiles.format,
|
|
})
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
|
.where(inArray(books.id, bookIds)),
|
|
this.db
|
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(inArray(bookAuthors.bookId, bookIds)),
|
|
]);
|
|
|
|
const authorsByBook = new Map<number, string[]>();
|
|
for (const row of authorRows) {
|
|
const names = authorsByBook.get(row.bookId) ?? [];
|
|
names.push(row.name);
|
|
authorsByBook.set(row.bookId, names);
|
|
}
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
updatedAt: r.updatedAt?.toISOString() ?? null,
|
|
hasCover: r.coverSource !== null,
|
|
authors: authorsByBook.get(r.id) ?? [],
|
|
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
|
|
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
|
|
}));
|
|
}
|
|
|
|
async findPatternMetadataByBookIds(bookIds: number[]): Promise<PatternMetadataRow[]> {
|
|
if (bookIds.length === 0) return [];
|
|
|
|
const [metaRows, authorRows] = await Promise.all([
|
|
this.db
|
|
.select({
|
|
bookId: books.id,
|
|
title: bookMetadata.title,
|
|
subtitle: bookMetadata.subtitle,
|
|
publisher: bookMetadata.publisher,
|
|
publishedDate: bookMetadata.publishedDate,
|
|
publishedYear: bookMetadata.publishedYear,
|
|
language: bookMetadata.language,
|
|
seriesId: bookMetadata.seriesId,
|
|
seriesName: bookMetadata.seriesName,
|
|
seriesIndex: bookMetadata.seriesIndex,
|
|
isbn13: bookMetadata.isbn13,
|
|
})
|
|
.from(books)
|
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
|
.where(inArray(books.id, bookIds)),
|
|
this.db
|
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
|
.where(inArray(bookAuthors.bookId, bookIds))
|
|
.orderBy(bookAuthors.displayOrder),
|
|
]);
|
|
|
|
const authorsByBookId = new Map<number, string[]>();
|
|
for (const row of authorRows) {
|
|
const list = authorsByBookId.get(row.bookId) ?? [];
|
|
list.push(row.name);
|
|
authorsByBookId.set(row.bookId, list);
|
|
}
|
|
|
|
return metaRows.map((row) => ({ ...row, authors: authorsByBookId.get(row.bookId) ?? [] }));
|
|
}
|
|
|
|
async findAllIds(): Promise<number[]> {
|
|
const rows = await this.db.select({ id: books.id }).from(books);
|
|
return rows.map((r) => r.id);
|
|
}
|
|
|
|
async findIdsByWhere(where: SQL | undefined): Promise<number[]> {
|
|
const rows = await this.db.select({ id: books.id }).from(books).where(this.visibleWhere(where));
|
|
return rows.map((r) => r.id);
|
|
}
|
|
|
|
async findPrimaryFile(bookId: number): Promise<{ absolutePath: string; format: string | null } | null> {
|
|
const [row] = await this.db
|
|
.select({ absolutePath: bookFiles.absolutePath, format: bookFiles.format })
|
|
.from(books)
|
|
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
|
.where(eq(books.id, bookId))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
async findPrimaryFilesByBookIds(
|
|
bookIds: number[],
|
|
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null }[]> {
|
|
if (bookIds.length === 0) return [];
|
|
return this.db
|
|
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format, sizeBytes: bookFiles.sizeBytes })
|
|
.from(books)
|
|
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
|
.where(inArray(books.id, bookIds))
|
|
.orderBy(asc(books.id));
|
|
}
|
|
|
|
async findPrimaryReaderFilesByBookIds(
|
|
bookIds: number[],
|
|
): Promise<{ id: number; bookId: number; absolutePath: string; format: string | null; fileHash: string | null; sizeBytes: number | null }[]> {
|
|
if (bookIds.length === 0) return [];
|
|
return this.db
|
|
.select({
|
|
id: bookFiles.id,
|
|
bookId: books.id,
|
|
absolutePath: bookFiles.absolutePath,
|
|
format: bookFiles.format,
|
|
fileHash: bookFiles.fileHash,
|
|
sizeBytes: bookFiles.sizeBytes,
|
|
})
|
|
.from(books)
|
|
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
|
.where(inArray(books.id, bookIds))
|
|
.orderBy(asc(books.id));
|
|
}
|
|
|
|
async findAllFilesByBookIds(
|
|
bookIds: number[],
|
|
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null; sortOrder: number }[]> {
|
|
if (bookIds.length === 0) return [];
|
|
return this.db
|
|
.select({
|
|
bookId: bookFiles.bookId,
|
|
absolutePath: bookFiles.absolutePath,
|
|
format: bookFiles.format,
|
|
sizeBytes: bookFiles.sizeBytes,
|
|
sortOrder: bookFiles.sortOrder,
|
|
})
|
|
.from(bookFiles)
|
|
.where(inArray(bookFiles.bookId, bookIds))
|
|
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id));
|
|
}
|
|
|
|
async deleteByIds(bookIds: number[]): Promise<void> {
|
|
await this.db.delete(books).where(inArray(books.id, bookIds));
|
|
}
|
|
|
|
async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise<void> {
|
|
if (bookIds.length === 0) return;
|
|
|
|
await this.db
|
|
.insert(userBookRatings)
|
|
.values(bookIds.map((bookId) => ({ userId, bookId, rating })))
|
|
.onConflictDoUpdate({
|
|
target: [userBookRatings.userId, userBookRatings.bookId],
|
|
set: { rating, updatedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
|
if (bookIds.length === 0) return new Map();
|
|
const rows = await executor
|
|
.select({ bookId: bookTags.bookId, name: tags.name })
|
|
.from(bookTags)
|
|
.innerJoin(tags, eq(bookTags.tagId, tags.id))
|
|
.where(inArray(bookTags.bookId, bookIds));
|
|
const result = new Map<number, string[]>();
|
|
for (const row of rows) {
|
|
const existing = result.get(row.bookId) ?? [];
|
|
existing.push(row.name);
|
|
result.set(row.bookId, existing);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async findAuthorsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
|
if (bookIds.length === 0) return new Map();
|
|
const rows = await executor
|
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
|
.from(bookAuthors)
|
|
.innerJoin(authors, eq(bookAuthors.authorId, authors.id))
|
|
.where(inArray(bookAuthors.bookId, bookIds))
|
|
.orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder));
|
|
const result = new Map<number, string[]>();
|
|
for (const row of rows) {
|
|
const existing = result.get(row.bookId) ?? [];
|
|
existing.push(row.name);
|
|
result.set(row.bookId, existing);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async findGenresByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
|
if (bookIds.length === 0) return new Map();
|
|
const rows = await executor
|
|
.select({ bookId: bookGenres.bookId, name: genres.name })
|
|
.from(bookGenres)
|
|
.innerJoin(genres, eq(bookGenres.genreId, genres.id))
|
|
.where(inArray(bookGenres.bookId, bookIds));
|
|
const result = new Map<number, string[]>();
|
|
for (const row of rows) {
|
|
const existing = result.get(row.bookId) ?? [];
|
|
existing.push(row.name);
|
|
result.set(row.bookId, existing);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async findNarratorsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
|
if (bookIds.length === 0) return new Map();
|
|
const rows = await executor
|
|
.select({ bookId: bookNarrators.bookId, name: narrators.name })
|
|
.from(bookNarrators)
|
|
.innerJoin(narrators, eq(bookNarrators.narratorId, narrators.id))
|
|
.where(inArray(bookNarrators.bookId, bookIds))
|
|
.orderBy(asc(bookNarrators.bookId), asc(bookNarrators.displayOrder));
|
|
const result = new Map<number, string[]>();
|
|
for (const row of rows) {
|
|
const existing = result.get(row.bookId) ?? [];
|
|
existing.push(row.name);
|
|
result.set(row.bookId, existing);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async updateMetadataFields(
|
|
bookId: number,
|
|
fields: Partial<typeof bookMetadata.$inferInsert>,
|
|
executor: MetadataUpdateExecutor = this.db,
|
|
): Promise<void> {
|
|
const shouldSyncSeries =
|
|
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
|
|
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
|
|
await executor.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
|
|
if (shouldSyncSeries) {
|
|
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId, executor);
|
|
}
|
|
await executor.update(books).set({ updatedAt: new Date() }).where(eq(books.id, bookId));
|
|
}
|
|
|
|
async replaceCommunityRatings(
|
|
bookId: number,
|
|
ratings: Array<{ provider: string; rating: number; ratingCount: number | null }>,
|
|
executor: MetadataUpdateExecutor = this.db,
|
|
): Promise<void> {
|
|
const now = new Date();
|
|
await executor.delete(bookCommunityRatings).where(eq(bookCommunityRatings.bookId, bookId));
|
|
if (ratings.length > 0) {
|
|
await executor.insert(bookCommunityRatings).values(
|
|
ratings.map((rating) => ({
|
|
bookId,
|
|
provider: rating.provider,
|
|
rating: rating.rating,
|
|
ratingCount: rating.ratingCount,
|
|
updatedAt: now,
|
|
})),
|
|
);
|
|
}
|
|
await executor.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
|
|
}
|
|
|
|
async bulkUpdateMetadataFields(
|
|
bookIds: number[],
|
|
fields: Partial<typeof bookMetadata.$inferInsert>,
|
|
executor: MetadataUpdateExecutor = this.db,
|
|
): Promise<void> {
|
|
if (bookIds.length === 0) return;
|
|
const shouldSyncSeries =
|
|
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
|
|
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
|
|
await executor.update(bookMetadata).set(patch).where(inArray(bookMetadata.bookId, bookIds));
|
|
if (shouldSyncSeries) {
|
|
await this.seriesMemberships?.syncPrimaryFromMetadataForBooks(bookIds, executor);
|
|
}
|
|
await executor.update(books).set({ updatedAt: new Date() }).where(inArray(books.id, bookIds));
|
|
}
|
|
|
|
async upsertProgress(
|
|
userId: number,
|
|
fileId: number,
|
|
cfi: string | null,
|
|
pageNumber: number | null,
|
|
percentage: number,
|
|
positionSeconds?: number | null,
|
|
koboLocationSource?: string | null,
|
|
koboLocationType?: string | null,
|
|
koboLocationValue?: string | null,
|
|
koboContentSourceProgressPercent?: number | null,
|
|
koreaderProgress?: string | null,
|
|
) {
|
|
const now = new Date();
|
|
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
|
|
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
|
|
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
|
|
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
|
|
const normalizedKoreaderProgress = this.normalizeKoreaderProgress(koreaderProgress);
|
|
await this.db
|
|
.insert(readingProgress)
|
|
.values({
|
|
userId,
|
|
bookFileId: fileId,
|
|
cfi,
|
|
pageNumber,
|
|
percentage,
|
|
positionSeconds: positionSeconds ?? null,
|
|
koboLocationSource: normalizedKoboLocationSource,
|
|
koboLocationType: normalizedKoboLocationType,
|
|
koboLocationValue: normalizedKoboLocationValue,
|
|
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
|
|
koreaderProgress: normalizedKoreaderProgress,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [readingProgress.bookFileId, readingProgress.userId],
|
|
set: {
|
|
cfi,
|
|
pageNumber,
|
|
percentage,
|
|
positionSeconds: positionSeconds ?? null,
|
|
koboLocationSource: normalizedKoboLocationSource,
|
|
koboLocationType: normalizedKoboLocationType,
|
|
koboLocationValue: normalizedKoboLocationValue,
|
|
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
|
|
koreaderProgress: normalizedKoreaderProgress,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
}
|
|
|
|
async syncKoboReadingStateFromProgress(
|
|
userId: number,
|
|
fileId: number,
|
|
percentage: number,
|
|
koboLocationSource?: string | null,
|
|
koboLocationType?: string | null,
|
|
koboLocationValue?: string | null,
|
|
koboContentSourceProgressPercent?: number | null,
|
|
): Promise<boolean> {
|
|
const [file] = await this.db
|
|
.select({
|
|
bookId: bookFiles.bookId,
|
|
primaryFileId: books.primaryFileId,
|
|
format: bookFiles.format,
|
|
})
|
|
.from(bookFiles)
|
|
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
|
.where(eq(bookFiles.id, fileId))
|
|
.limit(1);
|
|
|
|
if (!file || file.primaryFileId !== fileId || file.format !== 'epub') return false;
|
|
|
|
const clampedPercentage = this.clampProgressPercentage(percentage);
|
|
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
|
|
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
|
|
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
|
|
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
|
|
// Location values are optional: without them the bookmark advances percent-wise
|
|
// and the precise KoboSpan Location is computed server-side at delivery time.
|
|
const hasLocation = Boolean(normalizedKoboLocationSource && normalizedKoboLocationType === 'KoboSpan' && normalizedKoboLocationValue);
|
|
|
|
const now = new Date();
|
|
const nowIso = now.toISOString();
|
|
|
|
const [existing] = await this.db
|
|
.select({
|
|
entitlementId: koboReadingStates.entitlementId,
|
|
createdAtKobo: koboReadingStates.createdAtKobo,
|
|
currentBookmark: koboReadingStates.currentBookmark,
|
|
statistics: koboReadingStates.statistics,
|
|
statusInfo: koboReadingStates.statusInfo,
|
|
})
|
|
.from(koboReadingStates)
|
|
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, file.bookId)))
|
|
.limit(1);
|
|
|
|
const existingBookmark = this.asJsonObj(existing?.currentBookmark);
|
|
if (
|
|
hasLocation &&
|
|
this.isKoboBookmarkCurrent(
|
|
existingBookmark,
|
|
clampedPercentage,
|
|
normalizedKoboLocationSource,
|
|
normalizedKoboLocationType,
|
|
normalizedKoboLocationValue,
|
|
normalizedKoboContentSourceProgressPercent,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (!hasLocation && this.isKoboBookmarkPercentCurrent(existingBookmark, clampedPercentage)) {
|
|
return true;
|
|
}
|
|
|
|
const currentBookmark: JsonObj = {
|
|
...(existingBookmark ?? {}),
|
|
LastModified: nowIso,
|
|
ProgressPercent: clampedPercentage,
|
|
...(hasLocation
|
|
? {
|
|
Location: {
|
|
Source: normalizedKoboLocationSource,
|
|
Type: normalizedKoboLocationType,
|
|
Value: normalizedKoboLocationValue,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
if (hasLocation && normalizedKoboContentSourceProgressPercent !== null) {
|
|
currentBookmark.ContentSourceProgressPercent = normalizedKoboContentSourceProgressPercent;
|
|
} else if (hasLocation) {
|
|
delete currentBookmark.ContentSourceProgressPercent;
|
|
}
|
|
const statusInfo = {
|
|
...(this.asJsonObj(existing?.statusInfo) ?? {}),
|
|
LastModified: nowIso,
|
|
Status: this.deriveKoboStatus(clampedPercentage),
|
|
};
|
|
const statistics = this.asJsonObj(existing?.statistics) ?? { LastModified: nowIso };
|
|
|
|
await this.db
|
|
.insert(koboReadingStates)
|
|
.values({
|
|
userId,
|
|
bookId: file.bookId,
|
|
entitlementId: existing?.entitlementId ?? String(file.bookId),
|
|
createdAtKobo: existing?.createdAtKobo ?? nowIso,
|
|
lastModifiedKobo: nowIso,
|
|
priorityTimestamp: nowIso,
|
|
currentBookmark,
|
|
statistics,
|
|
statusInfo,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [koboReadingStates.userId, koboReadingStates.bookId],
|
|
set: {
|
|
lastModifiedKobo: nowIso,
|
|
priorityTimestamp: nowIso,
|
|
currentBookmark,
|
|
statistics,
|
|
statusInfo,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
|
|
await this.markKoboSnapshotBookUnsyncedForReadingState(userId, file.bookId);
|
|
return true;
|
|
}
|
|
|
|
async isKoboTwoWayProgressSyncEnabled(userId: number): Promise<boolean> {
|
|
const [settings] = await this.db
|
|
.select({ twoWayProgressSync: koboSyncSettings.twoWayProgressSync })
|
|
.from(koboSyncSettings)
|
|
.where(eq(koboSyncSettings.userId, userId))
|
|
.limit(1);
|
|
return settings?.twoWayProgressSync === true;
|
|
}
|
|
|
|
async clearFileProgress(userId: number, fileId: number): Promise<void> {
|
|
await this.db.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
|
|
await this.db.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
|
|
}
|
|
|
|
async findAudioProgress(userId: number, bookId: number) {
|
|
const [row] = await this.db
|
|
.select()
|
|
.from(audiobookProgress)
|
|
.where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
async upsertAudioProgress(userId: number, bookId: number, currentFileId: number, positionSeconds: number, percentage: number) {
|
|
const now = new Date();
|
|
const [row] = await this.db
|
|
.insert(audiobookProgress)
|
|
.values({ userId, bookId, currentFileId, positionSeconds, percentage, updatedAt: now })
|
|
.onConflictDoUpdate({
|
|
target: [audiobookProgress.userId, audiobookProgress.bookId],
|
|
set: { currentFileId, positionSeconds, percentage, updatedAt: now },
|
|
})
|
|
.returning();
|
|
return row;
|
|
}
|
|
|
|
private clampProgressPercentage(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.min(100, value));
|
|
}
|
|
|
|
private clampNullableProgressPercentage(value: number | null | undefined): number | null {
|
|
return typeof value === 'number' && Number.isFinite(value) ? this.clampProgressPercentage(value) : null;
|
|
}
|
|
|
|
private normalizeKoboLocationPart(value: string | null | undefined): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
private normalizeKoreaderProgress(value: string | null | undefined): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.startsWith('/body/DocFragment[') ? trimmed : null;
|
|
}
|
|
|
|
private asJsonObj(value: unknown): JsonObj | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
return value as JsonObj;
|
|
}
|
|
|
|
private extractKoboPercent(bookmark: JsonObj | null): number | null {
|
|
const candidate = bookmark?.ProgressPercent;
|
|
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
|
|
}
|
|
|
|
private extractKoboContentSourceProgressPercent(bookmark: JsonObj | null): number | null {
|
|
const candidate = bookmark?.ContentSourceProgressPercent;
|
|
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
|
|
}
|
|
|
|
private isKoboBookmarkCurrent(
|
|
bookmark: JsonObj | null,
|
|
percentage: number,
|
|
koboLocationSource: string | null,
|
|
koboLocationType: string | null,
|
|
koboLocationValue: string | null,
|
|
koboContentSourceProgressPercent: number | null,
|
|
): boolean {
|
|
const existingPercent = this.extractKoboPercent(bookmark);
|
|
if (existingPercent === null || Math.abs(existingPercent - percentage) >= PROGRESS_EPSILON) return false;
|
|
|
|
if (!koboLocationSource || !koboLocationType || !koboLocationValue) return !this.hasNonInternalBookmarkFields(bookmark);
|
|
|
|
const location = this.asJsonObj(bookmark?.Location);
|
|
if (location?.Source !== koboLocationSource || location.Type !== koboLocationType || location.Value !== koboLocationValue) return false;
|
|
|
|
if (koboContentSourceProgressPercent === null) {
|
|
return this.extractKoboContentSourceProgressPercent(bookmark) === null;
|
|
}
|
|
const existingSourcePercent = this.extractKoboContentSourceProgressPercent(bookmark);
|
|
return existingSourcePercent !== null && Math.abs(existingSourcePercent - koboContentSourceProgressPercent) < PROGRESS_EPSILON;
|
|
}
|
|
|
|
private hasNonInternalBookmarkFields(bookmark: JsonObj | null): boolean {
|
|
if (!bookmark) return false;
|
|
return Object.keys(bookmark).some((key) => key !== 'LastModified' && key !== 'ProgressPercent');
|
|
}
|
|
|
|
private isKoboBookmarkPercentCurrent(bookmark: JsonObj | null, percentage: number): boolean {
|
|
const existingPercent = this.extractKoboPercent(bookmark);
|
|
return existingPercent !== null && Math.abs(existingPercent - percentage) < PROGRESS_EPSILON;
|
|
}
|
|
|
|
private deriveKoboStatus(percentage: number): string {
|
|
if (percentage >= 100) return 'Finished';
|
|
return percentage > 0 ? 'Reading' : 'ReadyToRead';
|
|
}
|
|
|
|
private async markKoboSnapshotBookUnsyncedForReadingState(userId: number, bookId: number): Promise<void> {
|
|
await this.db.execute(sql`
|
|
UPDATE ${koboSnapshotBooks} AS sb
|
|
SET synced = false,
|
|
is_new = false
|
|
FROM ${koboLibrarySnapshots} AS snap
|
|
WHERE snap.id = sb.snapshot_id
|
|
AND snap.user_id = ${userId}
|
|
AND sb.book_id = ${bookId}
|
|
AND sb.pending_delete = false
|
|
AND sb.removed_by_device = false
|
|
`);
|
|
}
|
|
}
|