Files
lnreader/src/database/queries/ChapterQueries.ts
T
2026-07-02 01:27:44 +05:30

763 lines
18 KiB
TypeScript

import {
eq,
getColumns,
sql,
inArray,
and,
lte,
isNotNull,
desc,
asc,
count,
like,
or,
gt,
lt,
} from 'drizzle-orm';
import { showToast } from '@utils/showToast';
import { ChapterInfo, DownloadedChapter, Update } from '../types';
import { ChapterItem } from '@plugins/types';
import { getString } from '@strings/translations';
import { NOVEL_STORAGE } from '@utils/Storages';
import { dbManager } from '@database/db';
import { chapterSchema, novelSchema } from '@database/schema';
import NativeFile from '@specs/NativeFile';
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
import { chapterFilterToSQL, chapterOrderToSQL } from '@database/utils/parser';
import { castInt } from '@database/manager/manager';
// #region Mutations
/**
* Insert or update chapters using Drizzle ORM
*/
export const insertChapters = async (
novelId: number,
chapters?: ChapterItem[],
options?: {
page?: string;
touchUpdatedTime?: boolean;
preferNullReleaseTime?: boolean;
},
): Promise<void> => {
if (!chapters?.length) {
return;
}
const nowSql = sql`datetime('now','localtime')`;
const rows = chapters.map((c, index) => ({
path: c.path,
name: c.name || `Chapter ${index + 1}`,
releaseTime: c.releaseTime ?? (options?.preferNullReleaseTime ? null : ''),
novelId,
chapterNumber: c.chapterNumber ?? index + 1,
page: options?.page ?? c.page ?? '1',
position: index,
}));
await dbManager.batch(rows, (tx, ph) =>
tx
.insert(chapterSchema)
.values({
path: ph('path'),
name: ph('name'),
releaseTime: ph('releaseTime'),
novelId: ph('novelId'),
chapterNumber: ph('chapterNumber'),
page: ph('page'),
position: ph('position'),
...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}),
})
.onConflictDoUpdate({
target: [chapterSchema.novelId, chapterSchema.path],
set: {
page: sql`excluded.page`,
position: sql`excluded.position`,
name: sql`excluded.name`,
releaseTime: sql`excluded.releaseTime`,
chapterNumber: sql`excluded.chapterNumber`,
...(options?.touchUpdatedTime ? { updatedTime: nowSql } : {}),
},
where: sql`NOT (
${chapterSchema.page} IS excluded.page
AND ${chapterSchema.position} IS excluded.position
AND ${chapterSchema.name} IS excluded.name
AND ${chapterSchema.releaseTime} IS excluded.releaseTime
AND ${chapterSchema.chapterNumber} IS excluded.chapterNumber
)`,
})
.prepare(),
);
};
export const markChapterRead = async (chapterId: number): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(eq(chapterSchema.id, chapterId))
.run();
});
};
export const markChaptersRead = async (chapterIds: number[]): Promise<void> => {
if (!chapterIds.length) {
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
};
export const markChapterUnread = async (chapterId: number): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(eq(chapterSchema.id, chapterId))
.run();
});
};
export const markChaptersUnread = async (
chapterIds: number[],
): Promise<void> => {
if (!chapterIds.length) {
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
};
export const markAllChaptersRead = async (novelId: number): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(eq(chapterSchema.novelId, novelId))
.run();
});
};
export const markAllChaptersUnread = async (novelId: number): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(eq(chapterSchema.novelId, novelId))
.run();
});
};
const deleteDownloadedFiles = (
pluginId: string,
novelId: number,
chapterId: number,
) => {
try {
const chapterFolder = `${NOVEL_STORAGE}/${pluginId}/${novelId}/${chapterId}`;
NativeFile.unlink(chapterFolder);
} catch {
throw new Error(getString('novelScreen.deleteChapterError'));
}
};
// delete downloaded chapter
export const deleteChapter = async (
pluginId: string,
novelId: number,
chapterId: number,
): Promise<void> => {
deleteDownloadedFiles(pluginId, novelId, chapterId);
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(eq(chapterSchema.id, chapterId))
.run();
});
};
export const deleteChapters = async (
pluginId: string,
novelId: number,
chapters?: ChapterInfo[],
): Promise<void> => {
if (!chapters?.length) {
return;
}
const chapterIds = chapters.map(chapter => chapter.id);
chapters.forEach(chapter =>
deleteDownloadedFiles(pluginId, novelId, chapter.id),
);
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
};
// TODO: Remove the need for the chapters array, as it could lead to not deleting the downloaded files but just marking them as not downloaded
/*
Deletes all downloaded chapters from the database
*/
export const deleteDownloads = async (
chapters: DownloadedChapter[],
): Promise<void> => {
if (!chapters?.length) {
return;
}
chapters.forEach(chapter => {
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id);
});
await dbManager.write(async tx => {
await tx.update(chapterSchema).set({ isDownloaded: false }).run();
});
};
export const deleteReadChaptersFromDb = async (): Promise<void> => {
const chapters = await getReadDownloadedChapters();
chapters?.forEach(chapter => {
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id);
});
const chapterIds = chapters?.map(chapter => chapter.id);
if (chapterIds?.length) {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
}
showToast(getString('novelScreen.readChaptersDeleted'));
};
export const updateChapterProgress = async (
chapterId: number,
progress: number,
): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ progress })
.where(eq(chapterSchema.id, chapterId))
.run();
});
};
export const updateChapterProgressByIds = async (
chapterIds: number[],
progress: number,
): Promise<void> => {
if (!chapterIds.length) {
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ progress })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
};
export const bookmarkChapter = async (chapterId: number): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ bookmark: sql`NOT ${chapterSchema.bookmark}` })
.where(eq(chapterSchema.id, chapterId))
.run();
});
};
export const markPreviuschaptersRead = async (
chapterId: number,
novelId: number,
): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(
and(
lte(chapterSchema.id, chapterId),
eq(chapterSchema.novelId, novelId),
),
)
.run();
});
};
export const markPreviousChaptersUnread = async (
chapterId: number,
novelId: number,
): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(
and(
lte(chapterSchema.id, chapterId),
eq(chapterSchema.novelId, novelId),
),
)
.run();
});
};
export const clearUpdates = async (): Promise<void> => {
await dbManager.write(async tx => {
await tx.update(chapterSchema).set({ updatedTime: null }).run();
});
};
// #endregion
// #region Selectors
export const getCustomPages = (novelId: number) => {
return dbManager.allSync(
dbManager
.selectDistinct({ page: chapterSchema.page })
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId))
.orderBy(asc(castInt(chapterSchema.page))),
);
};
export const getNovelChapters = async (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
limit: number = 1000,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
!page ? sql.raw('true') : eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
)
.orderBy(chapterOrderToSQL(sort))
.limit(limit)
.all();
export const getNovelChaptersSync = (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
limit: number = 1000,
): ChapterInfo[] =>
dbManager.allSync(
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
!page ? sql.raw('true') : eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
)
.orderBy(chapterOrderToSQL(sort))
.limit(limit), // Adding a limit to prevent potential performance issues with large datasets
);
/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getUnreadNovelChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(
and(eq(chapterSchema.novelId, novelId), eq(chapterSchema.unread, true)),
);
/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getAllUndownloadedChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.isDownloaded, false),
),
);
/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
export const getAllUndownloadedAndUnreadChapters = async (
novelId: number,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.isDownloaded, false),
eq(chapterSchema.unread, true),
),
)
.all();
export const getChapter = async (chapterId: number) =>
dbManager
.select()
.from(chapterSchema)
.where(eq(chapterSchema.id, chapterId))
.get();
export const getPageChapters = async (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
offset?: number,
limit?: number,
): Promise<ChapterInfo[]> => {
const query = dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
chapterFilterToSQL(filter),
),
)
.$dynamic();
if (sort) {
query.orderBy(chapterOrderToSQL(sort));
}
if (limit !== undefined) {
query.limit(limit);
}
if (offset !== undefined) {
query.offset(offset);
}
return query.all();
};
export const getChapterCount = async (
novelId: number,
page: string = '1',
filter?: ChapterFilterKey[],
) =>
await dbManager.$count(
chapterSchema,
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
);
export const getChapterCountSync = (
novelId: number,
page: string = '1',
filter?: ChapterFilterKey[],
): number => {
// Using count(*) as name because the current drizzle version generates wrong type
const result = dbManager.getSync(
dbManager
.select({ 'count(*)': count() })
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page),
chapterFilterToSQL(filter),
),
),
);
return result?.['count(*)'] ?? 0;
};
export const getPageChaptersBatched = async (
novelId: number,
sort?: ChapterOrderKey,
filter?: ChapterFilterKey[],
page?: string,
batch: number = 0,
) => {
const limit = 1000;
const offset = 1000 * batch;
const query = dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
chapterFilterToSQL(filter),
),
)
.limit(limit)
.offset(offset)
.$dynamic();
if (sort) {
query.orderBy(chapterOrderToSQL(sort));
}
return query.all();
};
export const getNovelChaptersByNumber = async (
novelId: number,
chapterNumber: number,
) => {
return dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.position, chapterNumber - 1),
),
)
.all();
};
export const getFirstUnreadChapter = (
novelId: number,
filter?: ChapterFilterKey[],
page?: string,
) =>
dbManager.getSync(
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
eq(chapterSchema.unread, true),
chapterFilterToSQL(filter),
),
)
.orderBy(asc(chapterSchema.position))
.limit(1),
);
export const getNovelChaptersByName = async (
novelId: number,
searchText: string,
) => {
return dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
like(chapterSchema.name, `%${searchText}%`),
),
)
.all();
};
export const getPrevChapter = async (
novelId: number,
chapterPosition: number,
page: string,
) =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
or(
and(
eq(chapterSchema.page, castInt(page)),
lt(chapterSchema.position, castInt(chapterPosition)),
),
lt(chapterSchema.page, castInt(page)),
),
),
)
.orderBy(
desc(castInt(chapterSchema.page)),
desc(castInt(chapterSchema.position)),
)
.get();
export const getNextChapter = async (
novelId: number,
chapterPosition: number,
page: string,
) =>
dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
or(
and(
eq(chapterSchema.page, castInt(page)),
gt(chapterSchema.position, castInt(chapterPosition)),
),
and(
gt(chapterSchema.page, castInt(page)),
eq(chapterSchema.position, 0),
),
),
),
)
.orderBy(
asc(castInt(chapterSchema.page)),
asc(castInt(chapterSchema.position)),
)
.get();
const getReadDownloadedChapters = async () =>
dbManager
.select({
id: chapterSchema.id,
novelId: chapterSchema.novelId,
pluginId: novelSchema.pluginId,
})
.from(chapterSchema)
.innerJoin(novelSchema, eq(novelSchema.id, chapterSchema.novelId))
.where(
and(
eq(chapterSchema.unread, false),
eq(chapterSchema.isDownloaded, true),
),
)
.all();
export const getDownloadedChapters = async () =>
dbManager
.select({
...getColumns(chapterSchema),
pluginId: novelSchema.pluginId,
novelName: novelSchema.name,
novelCover: novelSchema.cover,
novelPath: novelSchema.path,
})
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(chapterSchema.isDownloaded, true))
.all();
export const getNovelDownloadedChapters = async (
novelId: number,
startPosition?: number,
endPosition?: number,
): Promise<ChapterInfo[]> => {
const query = dbManager
.select()
.from(chapterSchema)
.where(
and(
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.isDownloaded, true),
),
)
.orderBy(
asc(castInt(chapterSchema.page)),
asc(chapterSchema.position),
)
.$dynamic();
if (startPosition !== undefined && endPosition !== undefined) {
query.limit(endPosition - startPosition + 1).offset(startPosition - 1);
}
return query.all();
};
export const getUpdatedOverviewFromDb = async () =>
dbManager
.select({
novelId: novelSchema.id,
novelName: novelSchema.name,
novelCover: novelSchema.cover,
novelPath: novelSchema.path,
updateDate: sql<string>`DATE(${chapterSchema.updatedTime})`.as(
'update_date',
),
updatesPerDay: count(),
})
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(isNotNull(chapterSchema.updatedTime))
.groupBy(novelSchema.id, sql`update_date`)
.orderBy(desc(sql`update_date`), novelSchema.id)
.all();
export const getDetailedUpdatesFromDb = async (
novelId: number,
onlyDownloadableChapters?: boolean,
): Promise<Update[]> => {
return dbManager
.select({
...getColumns(chapterSchema),
pluginId: novelSchema.pluginId,
novelId: novelSchema.id,
novelName: novelSchema.name,
novelPath: novelSchema.path,
novelCover: novelSchema.cover,
})
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(
and(
eq(novelSchema.id, novelId),
onlyDownloadableChapters
? eq(chapterSchema.isDownloaded, true)
: isNotNull(chapterSchema.updatedTime),
),
)
.orderBy(desc(chapterSchema.updatedTime))
.all();
};
export const isChapterDownloaded = (chapterId: number): boolean => {
const result = dbManager.getSync(
dbManager
.select({ id: chapterSchema.id })
.from(chapterSchema)
.where(
and(
eq(chapterSchema.id, chapterId),
eq(chapterSchema.isDownloaded, true),
),
),
);
return !!result;
};