diff --git a/README.md b/README.md index 7c6c1d79d..6a24a9c95 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Community-driven plugin repository for [LNReader](https://github.com/LNReader/ln ## Quick Start -**Prerequisites:** Node.js >= 20 +**Prerequisites:** Node.js >= 20 ```bash npm install diff --git a/icons/src/en/novelrest/icon.png b/icons/src/en/novelrest/icon.png new file mode 100644 index 000000000..11703697d Binary files /dev/null and b/icons/src/en/novelrest/icon.png differ diff --git a/plugins/english/novelrest.ts b/plugins/english/novelrest.ts new file mode 100644 index 000000000..ea1c33427 --- /dev/null +++ b/plugins/english/novelrest.ts @@ -0,0 +1,229 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +/** + * NovelRest LNReader Plugin + * + * This plugin allows LNReader to fetch novels from NovelRest (novelrest.vercel.app) + * + * Features: + * - Browse popular novels with pagination + * - Search novels + * - Read chapters + * - Filter by status + * - Sort by latest/popular/rating/updated + */ +class NovelRestPlugin implements Plugin.PluginBase { + id = 'novelrest'; + name = 'NovelRest'; + icon = 'src/en/novelrest/icon.png'; + site = 'https://novelrest.vercel.app'; + apiBase = 'https://novelrest.vercel.app/api/lnreader'; + version = '1.0.0'; + + filters: Filters = { + status: { + type: FilterTypes.Picker, + label: 'Status', + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Ongoing', value: 'ONGOING' }, + { label: 'Completed', value: 'COMPLETED' }, + { label: 'Hiatus', value: 'HIATUS' }, + ], + }, + sort: { + type: FilterTypes.Picker, + label: 'Sort By', + value: 'latest', + options: [ + { label: 'Latest', value: 'latest' }, + { label: 'Popular', value: 'popular' }, + { label: 'Rating', value: 'rating' }, + { label: 'Updated', value: 'updated' }, + ], + }, + }; + + /** + * Fetch popular/latest novels with pagination + */ + async popularNovels( + pageNo: number, + { showLatestNovels, filters }: Plugin.PopularNovelsOptions, + ): Promise { + const novels: Plugin.NovelItem[] = []; + + // Build query parameters + const params = new URLSearchParams(); + params.set('page', pageNo.toString()); + params.set('limit', '20'); + + if (filters?.status?.value) { + params.set('status', filters.status.value as string); + } + + const sortBy = showLatestNovels ? 'latest' : (filters?.sort?.value as string || 'popular'); + params.set('sort', sortBy); + + try { + const url = `${this.apiBase}/novels?${params.toString()}`; + const response = await fetchApi(url); + const data = await response.json(); + + if (data.novels && Array.isArray(data.novels)) { + for (const novel of data.novels) { + novels.push({ + name: novel.title, + path: novel.slug, // Just the slug, we'll build full path in parseNovel + cover: novel.coverImage || defaultCover, + }); + } + } + } catch (error) { + console.error('NovelRest: Error fetching popular novels:', error); + } + + return novels; + } + + /** + * Parse novel details and chapter list + */ + async parseNovel(novelPath: string): Promise { + const novel: Plugin.SourceNovel = { + path: novelPath, + name: 'Untitled', + }; + + try { + // novelPath is just the slug + const slug = novelPath.replace(/^\/novels\//, ''); // Clean up if path prefix exists + + const response = await fetchApi(`${this.apiBase}/novels/${slug}`); + const data = await response.json(); + + if (data) { + novel.name = data.title || 'Untitled'; + novel.author = data.author || ''; + novel.cover = data.coverImage || defaultCover; + novel.genres = Array.isArray(data.genres) + ? data.genres.map((g: { name: string } | string) => typeof g === 'string' ? g : g.name).join(', ') + : ''; + novel.summary = data.description || ''; + + // Map status + switch (data.status) { + case 'COMPLETED': + novel.status = NovelStatus.Completed; + break; + case 'ONGOING': + novel.status = NovelStatus.Ongoing; + break; + case 'HIATUS': + novel.status = NovelStatus.OnHiatus; + break; + default: + novel.status = NovelStatus.Unknown; + } + + // Parse chapters + const chapters: Plugin.ChapterItem[] = []; + + if (data.chapters && Array.isArray(data.chapters)) { + for (const chapter of data.chapters) { + chapters.push({ + name: chapter.title || `Chapter ${chapter.number}`, + path: `${slug}/${chapter.number}`, // slug/chapterNumber format + releaseTime: chapter.createdAt || undefined, + chapterNumber: chapter.number, + }); + } + } + + novel.chapters = chapters; + } + } catch (error) { + console.error('NovelRest: Error parsing novel:', error); + } + + return novel; + } + + /** + * Parse chapter content + */ + async parseChapter(chapterPath: string): Promise { + try { + // chapterPath format: "slug/chapterNumber" + const parts = chapterPath.split('/'); + const chapterNumber = parts.pop(); + const slug = parts.join('/'); + + const response = await fetchApi(`${this.apiBase}/novels/${slug}/chapters/${chapterNumber}`); + const data = await response.json(); + + if (data && data.contentHtml) { + return data.contentHtml; + } + + return '

Chapter content could not be loaded.

'; + } catch (error) { + console.error('NovelRest: Error parsing chapter:', error); + return '

Error loading chapter content.

'; + } + } + + /** + * Search novels by term + */ + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const novels: Plugin.NovelItem[] = []; + + try { + const params = new URLSearchParams(); + params.set('q', searchTerm); + params.set('page', pageNo.toString()); + params.set('limit', '20'); + + const response = await fetchApi(`${this.apiBase}/novels?${params.toString()}`); + const data = await response.json(); + + if (data.novels && Array.isArray(data.novels)) { + for (const novel of data.novels) { + novels.push({ + name: novel.title, + path: novel.slug, + cover: novel.coverImage || defaultCover, + }); + } + } + } catch (error) { + console.error('NovelRest: Error searching novels:', error); + } + + return novels; + } + + /** + * Resolve full URL for novel or chapter + */ + resolveUrl = (path: string, isNovel?: boolean): string => { + if (isNovel) { + return `${this.site}/novels/${path}`; + } + // For chapters, path is "slug/chapterNumber" + return `${this.site}/novels/${path}`; + }; +} + +export default new NovelRestPlugin(); + +// trigger build