diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index a96a07f1af..e48d92698b 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -129,19 +129,22 @@ const getPrevChapterQuery = ` Chapter WHERE novelId = ? - AND - id < ? + AND + ((position < ? AND page = ?) OR page < ?) + ORDER BY + position DESC, page DESC `; export const getPrevChapter = ( novelId: number, - chapterId: number, + chapterPosition: number, + page: string, ): Promise => { return new Promise(resolve => db.transaction(tx => { tx.executeSql( getPrevChapterQuery, - [novelId, chapterId], + [novelId, chapterPosition, page, page], (_txObj, results) => resolve(results.rows.item(results.rows.length - 1)), () => { @@ -160,19 +163,22 @@ const getNextChapterQuery = ` Chapter WHERE novelId = ? - AND - id > ? + AND + ((position > ? AND page = ?) OR (position = 0 AND page > ?)) + ORDER BY + position ASC , page ASC `; export const getNextChapter = ( novelId: number, - chapterId: number, + chapterPosition: number, + page: string, ): Promise => { return new Promise(resolve => db.transaction(tx => { tx.executeSql( getNextChapterQuery, - [novelId, chapterId], + [novelId, chapterPosition, page, page], (_txObj, results) => resolve(results.rows.item(0)), () => { showToast(getString('readerScreen.noNextChapter')); @@ -384,16 +390,16 @@ export const bookmarkChapter = async (chapterId: number) => { }); }; -const markPreviuschaptersReadQuery = +const markPreviousChaptersReadQuery = 'UPDATE Chapter SET `unread` = 0 WHERE id <= ? AND novelId = ?'; -export const markPreviuschaptersRead = async ( +export const markPreviousChaptersRead = async ( chapterId: number, novelId: number, ) => { db.transaction(tx => { tx.executeSql( - markPreviuschaptersReadQuery, + markPreviousChaptersReadQuery, [chapterId, novelId], (_txObj, _res) => {}, (_txObj, _error) => { @@ -405,7 +411,7 @@ export const markPreviuschaptersRead = async ( }; const markPreviousChaptersUnreadQuery = - 'UPDATE Chapter SET `unread` = 1 WHERE id <= ? AND novelId = ?'; + 'UPDATE Chapter SET `unread` = 1 WHERE id >= ? AND novelId = ?'; export const markPreviousChaptersUnread = async ( chapterId: number, @@ -424,6 +430,48 @@ export const markPreviousChaptersUnread = async ( }); }; +const updatePreviousChapterReadProgressQuery = + 'UPDATE Chapter SET `progress` = ? WHERE `unread` = 0 AND id <= ? AND novelId = ?'; + +export const updatePreviousChapterReadProgress = async ( + chapterId: number, + novelId: number, + progress: number, +) => { + db.transaction(tx => { + tx.executeSql( + updatePreviousChapterReadProgressQuery, + [progress, chapterId, novelId], + (_txObj, _res) => {}, + (_txObj, _error) => { + // console.log(error) + return false; + }, + ); + }); +}; + +const updatePreviousChapterUnreadProgressQuery = + 'UPDATE Chapter SET `progress` = ? WHERE `unread` = 1, id >= ? AND novelId = ?'; + +export const updatePreviousChapterUnreadProgress = async ( + chapterId: number, + novelId: number, + progress: number, +) => { + db.transaction(tx => { + tx.executeSql( + updatePreviousChapterUnreadProgressQuery, + [progress, chapterId, novelId], + (_txObj, _res) => {}, + (_txObj, _error) => { + // console.log(error) + return false; + }, + ); + }); +}; + const getDownloadedChaptersQuery = ` SELECT Chapter.*, diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index 9bb8a0d03e..2e6780a253 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -14,9 +14,13 @@ import { bookmarkChapter as _bookmarkChapter, markChapterRead as _markChapterRead, markChaptersRead as _markChaptersRead, - markPreviuschaptersRead as _markPreviuschaptersRead, - markPreviousChaptersUnread as _markPreviousChaptersUnread, markChaptersUnread as _markChaptersUnread, + updateChapterProgress as _updateChapterProgress, + updateChapterProgressByIds as _updateChapterProgressByIds, + markPreviousChaptersRead as _markPreviousChaptersRead, + markPreviousChaptersUnread as _markPreviousChaptersUnread, + updatePreviousChapterReadProgress as _updatePreviousChapterReadProgress, + updatePreviousChapterUnreadProgress as _updatePreviousChapterUnreadProgress, deleteChapter as _deleteChapter, deleteChapters as _deleteChapters, getPageChapters as _getPageChapters, @@ -32,6 +36,8 @@ import { parseChapterNumber } from '@utils/parseChapterNumber'; import { NOVEL_STORAGE } from '@utils/Storages'; import FileManager from '@native/FileManager'; import { useAppSettings } from './useSettings'; +import { getPlugin } from '@plugins/pluginManager'; +import { ChapterItem, Plugin } from '@plugins/types'; // store key: '__', // store key: '_', @@ -208,74 +214,208 @@ export const useNovel = (novelPath: string, pluginId: string) => { ); }; - const markPreviouschaptersRead = (chapterId: number) => { - if (novel) { - _markPreviuschaptersRead(chapterId, novel.id); - setChapters( - chapters.map(chapter => - chapter.id <= chapterId ? { ...chapter, unread: false } : chapter, - ), - ); + // Helper function to handle common plugin sync logic + const syncChapterWithPlugin = async ( + findSyncedChapterCallback: () => ChapterItem | undefined, + inChapter: boolean = false, + ) => { + if (!novel) { + return; + } + + const plugin = getPlugin(novel.pluginId); + if (!plugin?.syncChapter) { + return; + } + + const syncedChapter = findSyncedChapterCallback(); + if (syncedChapter) { + await syncChapterStatus(plugin, syncedChapter, inChapter); + } + }; + + const syncChapterStatus = async ( + plugin: Plugin, + chapter: ChapterItem, + inChapter: boolean, + ) => { + if (!plugin.handleChapterEvent) { + return; + } + + try { + const success = await plugin.handleChapterEvent(novelPath, chapter); + + if (!inChapter) { + // Show a toast with the result + showToast( + getString( + success ? 'novelScreen.syncTrue' : 'novelScreen.syncFalse', + { + name: chapter.name, + }, + ), + ); + } + } catch (error) { + if (!inChapter) { + // Show a toast with the error message + showToast( + getString('novelScreen.syncError', { + name: chapter.name, + error: error, + }), + ); + } } }; const markChapterRead = (chapterId: number) => { + if (!novel) { + return; + } + + syncChapterWithPlugin(() => + chapters.find(chapter => chapter.id === chapterId), + ); + _markChapterRead(chapterId); + _updateChapterProgress(chapterId, 0); + setChapters( - chapters.map(c => { - if (c.id !== chapterId) { - return c; - } - return { - ...c, - unread: false, - }; - }), + chapters.map(chapter => ({ + ...chapter, + unread: chapter.id === chapterId ? false : chapter.unread, + progress: chapter.id === chapterId ? 0 : chapter.progress, + })), ); }; const markChaptersRead = (_chapters: ChapterInfo[]) => { + if (!novel) { + return; + } + + syncChapterWithPlugin(() => { + // Sort the selected chapters based on the position (highest position first) + const sortedChapters = [..._chapters].sort( + (a, b) => (b.position ?? 0) - (a.position ?? 0), + ); + return sortedChapters[0]; // Return the chapter with the highest position + }); + const chapterIds = _chapters.map(chapter => chapter.id); _markChaptersRead(chapterIds); + _updateChapterProgressByIds(chapterIds, 0); setChapters( - chapters.map(chapter => { - if (chapterIds.includes(chapter.id)) { - return { - ...chapter, - unread: false, - }; - } - return chapter; - }), + chapters.map(chapter => ({ + ...chapter, + unread: chapterIds.includes(chapter.id) ? false : chapter.unread, + progress: chapterIds.includes(chapter.id) ? 0 : chapter.progress, + })), ); }; - const markPreviousChaptersUnread = (chapterId: number) => { - if (novel) { - _markPreviousChaptersUnread(chapterId, novel.id); - setChapters( - chapters.map(chapter => - chapter.id <= chapterId ? { ...chapter, unread: true } : chapter, - ), - ); + const markChaptersUnread = (_chapters: ChapterInfo[]) => { + if (!novel) { + return; } - }; - const markChaptersUnread = (_chapters: ChapterInfo[]) => { + syncChapterWithPlugin(() => { + // Filter for chapters that are read + const readChapters = _chapters.filter(chapter => !chapter.unread); + + // Only proceed if there's at least one read chapter + if (!readChapters.length) { + return; + } + + // Sort the selected chapters based on the position (lowest position first) + const sortedChapters = [...readChapters].sort( + (a, b) => (a.position ?? 0) - (b.position ?? 0), + ); + + // Get the first chapter from the sorted list + const firstChapter = chapters.find( + chapter => chapter.id === sortedChapters[0]?.id, + ); + const firstChapterPosition = firstChapter?.position ?? 0; + + // Attempt to find the chapter immediately preceding the first chapter; + // if not found, fallback to the first chapter itself + const previousChapter = chapters.find( + chapter => chapter.position === firstChapterPosition - 1, + ); + + return previousChapter || firstChapter; + }); + const chapterIds = _chapters.map(chapter => chapter.id); _markChaptersUnread(chapterIds); + _updateChapterProgressByIds(chapterIds, 0); setChapters( - chapters.map(chapter => { - if (chapterIds.includes(chapter.id)) { - return { - ...chapter, - unread: true, - }; - } - return chapter; - }), + chapters.map(chapter => ({ + ...chapter, + unread: chapterIds.includes(chapter.id) ? true : chapter.unread, + progress: chapterIds.includes(chapter.id) ? 0 : chapter.progress, + })), + ); + }; + + const markPreviousChaptersRead = (chapterId: number) => { + if (!novel) { + return; + } + + syncChapterWithPlugin(() => { + const chapterPosition = chapters.find( + chapter => chapter.id === chapterId, + )?.position; + return chapters.find(chapter => chapter.position === chapterPosition); + }); + + _markPreviousChaptersRead(chapterId, novel.id); + _updatePreviousChapterReadProgress(chapterId, novel.id, 0); + + setChapters( + chapters.map(chapter => ({ + ...chapter, + unread: chapter.id <= chapterId ? false : chapter.unread, + progress: chapter.id <= chapterId ? 0 : chapter.progress, + })), + ); + }; + + const markPreviousChaptersUnread = (chapterId: number) => { + if (!novel) { + return; + } + + syncChapterWithPlugin(() => { + const currentChapter = chapters.find(chapter => chapter.id === chapterId); + if (!currentChapter) { + return; + } + + const chapterPosition = currentChapter.position ?? 0; + // Find the previous chapter or fall back to the current chapter + return ( + chapters.find(chapter => chapter.position === chapterPosition - 1) || + currentChapter + ); + }); + + _markPreviousChaptersUnread(chapterId, novel.id); + _updatePreviousChapterUnreadProgress(chapterId, novel.id, 0); + + setChapters( + chapters.map(chapter => ({ + ...chapter, + unread: chapter.id >= chapterId ? true : chapter.unread, + progress: chapter.id >= chapterId ? 0 : chapter.progress, + })), ); }; @@ -406,7 +546,8 @@ export const useNovel = (novelPath: string, pluginId: string) => { sortAndFilterChapters, followNovel, bookmarkChapters, - markPreviouschaptersRead, + syncChapterWithPlugin, + markPreviousChaptersRead, markChaptersRead, markPreviousChaptersUnread, markChaptersUnread, diff --git a/src/plugins/types/index.ts b/src/plugins/types/index.ts index 1f70cf6132..9316c12f46 100644 --- a/src/plugins/types/index.ts +++ b/src/plugins/types/index.ts @@ -77,7 +77,12 @@ export interface Plugin extends PluginItem { parseNovel: (novelPath: string) => Promise; parsePage?: (novelPath: string, page: string) => Promise; parseChapter: (chapterPath: string) => Promise; + handleChapterEvent?: ( + novelPath: string, + chapter: ChapterItem, + ) => Promise; searchNovels: (searchTerm: string, pageNo: number) => Promise; resolveUrl?: (path: string, isNovel?: boolean) => string; webStorageUtilized?: boolean; + syncChapter?: boolean; } diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index 12e0f2d620..ca20d5edea 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -50,7 +50,6 @@ import { useFocusEffect } from '@react-navigation/native'; import { isNumber } from 'lodash-es'; import NovelAppbar from './components/NovelAppbar'; import { resolveUrl } from '@services/plugin/fetch'; -import { updateChapterProgressByIds } from '@database/queries/ChapterQueries'; const Novel = ({ route, navigation }: NovelScreenProps) => { const { name, path, pluginId } = route.params; @@ -82,7 +81,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { bookmarkChapters, markChaptersRead, markChaptersUnread, - markPreviouschaptersRead, + markPreviousChaptersRead, markPreviousChaptersUnread, followNovel, deleteChapter, @@ -242,14 +241,11 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { }); } - if (selected.some(obj => !obj.unread)) { - const chapterIds = selected.map(chapter => chapter.id); - + if (selected.some(obj => !obj.unread || obj.progress! > 0)) { list.push({ icon: 'check-outline', onPress: () => { markChaptersUnread(selected); - updateChapterProgressByIds(chapterIds, 0); setSelected([]); refreshChapters(); }, @@ -261,7 +257,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => { list.push({ icon: 'playlist-check', onPress: () => { - markPreviouschaptersRead(selected[0].id); + markPreviousChaptersRead(selected[0].id); setSelected([]); }, }); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index 3574102cd0..56c624c882 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -17,7 +17,7 @@ import { import FileManager from '@native/FileManager'; import { fetchChapter } from '@services/plugin/fetch'; import { NOVEL_STORAGE } from '@utils/Storages'; -import { RefObject, useCallback, useEffect, useState } from 'react'; +import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { sanitizeChapterText } from '../utils/sanitizeChapterText'; import { parseChapterNumber } from '@utils/parseChapterNumber'; import WebView from 'react-native-webview'; @@ -48,6 +48,7 @@ export default function useChapter(webViewRef: RefObject) { const { tracker } = useTracker(); const { trackedNovel, updateNovelProgess } = useTrackedNovel(novel.id); const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode(); + const hasMarkedReadRef = useRef(false); const connectVolumeButton = () => { VolumeButtonListener.connect(); @@ -102,8 +103,8 @@ export default function useChapter(webViewRef: RefObject) { sanitizeChapterText(novel.pluginId, novel.name, chapter.name, text), ); const [nextChap, prevChap] = await Promise.all([ - getNextChapter(chapter.novelId, chapter.id), - getPrevChapter(chapter.novelId, chapter.id), + getNextChapter(chapter.novelId, chapter.position!, chapter.page), + getPrevChapter(chapter.novelId, chapter.position!, chapter.page), ]); setAdjacentChapter([nextChap!, prevChap!]); } catch (e: any) { @@ -138,19 +139,61 @@ export default function useChapter(webViewRef: RefObject) { } }; + // Set the ref based on chapter's read status + useEffect(() => { + // If chapter is already marked as read, set ref to true + hasMarkedReadRef.current = !chapter.unread; + }, [chapter.id, chapter.unread]); + + const { syncChapterWithPlugin, chapters } = useNovel( + novel.path, + novel.pluginId, + ); + const saveProgress = useCallback( (percentage: number) => { - if (!incognitoMode) { - updateChapterProgress(chapter.id, percentage > 100 ? 100 : percentage); + // Normalize percentage to be at most 100 + const validPercentage = percentage > 100 ? 100 : percentage; + const markChapterPercentage = 97; + + // Only update progress if we're not in incognito mode AND + // the chapter was already read OR + // we haven't just marked it as read in this session OR + // we have just marked it as read in this session but the progress is less than the markChapterPercentage + if ( + !incognitoMode && + (!chapter.unread || + !hasMarkedReadRef.current || + (hasMarkedReadRef.current && validPercentage < markChapterPercentage)) + ) { + updateChapterProgress(chapter.id, validPercentage); } - if (!incognitoMode && percentage >= 97) { - // a relative number + // Mark chapter as read when reaching 97% if not already marked in this session + if ( + !incognitoMode && + validPercentage >= markChapterPercentage && + !hasMarkedReadRef.current + ) { + syncChapterWithPlugin(() => { + return chapters.find( + currentChapter => currentChapter.id === chapter.id, + ); + }, true); // Pass inChapter as true + markChapterRead(chapter.id); updateTracker(); + hasMarkedReadRef.current = true; } }, - [chapter], + [ + incognitoMode, + chapter, + chapters, + syncChapterWithPlugin, + updateTracker, + hasMarkedReadRef, + ], ); const hideHeader = () => { diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index eae20b9cc5..f66b2ca339 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -400,6 +400,9 @@ }, "tracked": "Tracked", "tracking": "Tracking", + "syncTrue": "Synced to '%{name}'", + "syncFalse": "Failed to sync to '%{name}'", + "syncError": "Error syncing to '%{name}': %{error}", "unknownStatus": "Unknown status", "updatedToast": "Updated %{name}" }, @@ -501,4 +504,4 @@ "dark": "Dark", "complete": "Complete" } -} \ No newline at end of file +} diff --git a/strings/types/index.ts b/strings/types/index.ts index 3ccc898bfa..c1acc27a20 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -111,6 +111,7 @@ export interface StringMap { 'browseScreen.settings.description': 'string'; 'browseSettings': 'string'; 'browseSettingsScreen.concurrentSearches': 'string'; + 'browseSettingsScreen.multi': 'string'; 'browseSettingsScreen.languages': 'string'; 'categories.addCategories': 'string'; 'categories.cantDeleteDefault': 'string'; @@ -322,6 +323,9 @@ export interface StringMap { 'novelScreen.status.unknown': 'string'; 'novelScreen.tracked': 'string'; 'novelScreen.tracking': 'string'; + 'novelScreen.syncTrue': 'string'; + 'novelScreen.syncFalse': 'string'; + 'novelScreen.syncError': 'string'; 'novelScreen.unknownStatus': 'string'; 'novelScreen.updatedToast': 'string'; 'readerScreen.bottomSheet.allowTextSelection': 'string';