diff --git a/src/database/db.ts b/src/database/db.ts index d90b41655..6c8cc274c 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -12,6 +12,10 @@ import { } from './tables/ChapterTable'; import { dbTxnErrorCallback } from './utils/helpers'; import { noop } from 'lodash-es'; +import { + createRepositoryTableQuery, + insertDefaultRepository, +} from './tables/RepositoryTable'; const dbName = 'lnreader.db'; @@ -28,6 +32,11 @@ export const createTables = () => { tx.executeSql(createChapterTableQuery); tx.executeSql(createChapterNovelIdIndexQuery); }); + + db.transaction(tx => { + tx.executeSql(createRepositoryTableQuery); + tx.executeSql(insertDefaultRepository); + }); }; /** @@ -41,6 +50,7 @@ export const deleteDatabase = async () => { tx.executeSql('DROP TABLE NovelCategory'); tx.executeSql('DROP TABLE Chapter'); tx.executeSql('DROP TABLE Download'); + tx.executeSql('DROP TABLE Repository'); }, dbTxnErrorCallback, noop, diff --git a/src/database/queries/RepositoryQueries.ts b/src/database/queries/RepositoryQueries.ts new file mode 100644 index 000000000..6d200c830 --- /dev/null +++ b/src/database/queries/RepositoryQueries.ts @@ -0,0 +1,65 @@ +import * as SQLite from 'expo-sqlite'; + +import { Repository } from '@database/types'; + +import { txnErrorCallback } from '../utils/helpers'; +import { noop } from 'lodash-es'; + +const db = SQLite.openDatabase('lnreader.db'); + +const getRepositoriesQuery = 'SELECT * FROM Repository'; + +export const getRepositoriesFromDb = async (): Promise => { + return new Promise(resolve => + db.transaction(tx => { + tx.executeSql( + getRepositoriesQuery, + [], + (txObj, { rows }) => resolve((rows as any)._array), + txnErrorCallback, + ); + }), + ); +}; + +const isRepoUrlDuplicateQuery = ` + SELECT COUNT(*) as isDuplicate FROM Repository WHERE url = ? + `; + +export const isRepoUrlDuplicate = (repoUrl: string): Promise => { + return new Promise(resolve => + db.transaction(tx => { + tx.executeSql( + isRepoUrlDuplicateQuery, + [repoUrl], + (txObj, { rows }) => { + const { _array } = rows as any; + resolve(Boolean(_array[0]?.isDuplicate)); + }, + txnErrorCallback, + ); + }), + ); +}; + +const createRepositoryQuery = 'INSERT INTO Repository (url) VALUES (?)'; + +export const createRepository = (repoUrl: string): void => + db.transaction(tx => + tx.executeSql(createRepositoryQuery, [repoUrl], noop, txnErrorCallback), + ); + +const deleteRepositoryQuery = 'DELETE FROM Repository WHERE id = ?'; + +export const deleteRepositoryById = (id: number): void => { + db.transaction(tx => { + tx.executeSql(deleteRepositoryQuery, [id], noop, txnErrorCallback); + }); +}; + +const updateRepositoryQuery = 'UPDATE Repository SET name = ? WHERE id = ?'; + +export const updateRepository = (id: number, url: string): void => + db.transaction(tx => + tx.executeSql(updateRepositoryQuery, [url, id], noop, txnErrorCallback), + ); diff --git a/src/database/tables/RepositoryTable.ts b/src/database/tables/RepositoryTable.ts new file mode 100644 index 000000000..45387d64f --- /dev/null +++ b/src/database/tables/RepositoryTable.ts @@ -0,0 +1,10 @@ +export const createRepositoryTableQuery = ` + CREATE TABLE IF NOT EXISTS Repository ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + UNIQUE(url) + ); +`; + +export const insertDefaultRepository = + 'INSERT OR REPLACE INTO Repository (id, url) VALUES (1, "https://raw.githubusercontent.com/LNReader/lnreader-plugins/plugins/v2.1.0/.dist/plugins.min.json");'; diff --git a/src/database/types/index.ts b/src/database/types/index.ts index 3553818f7..07dc3ca59 100644 --- a/src/database/types/index.ts +++ b/src/database/types/index.ts @@ -93,3 +93,8 @@ export interface BackupNovel extends NovelInfo { export interface BackupCategory extends Category { novelIds: number[]; } + +export interface Repository { + id: number; + url: string; +} diff --git a/src/hooks/persisted/usePlugins.ts b/src/hooks/persisted/usePlugins.ts index b0caf920f..52e311dd4 100644 --- a/src/hooks/persisted/usePlugins.ts +++ b/src/hooks/persisted/usePlugins.ts @@ -47,7 +47,14 @@ export default function usePlugins() { ); setFilteredAvailablePlugins( orderBy( - availablePlugins.filter(plg => filter.includes(plg.lang)), + availablePlugins + .filter( + avalilablePlugin => + !installedPlugins.some( + installedPlugin => installedPlugin.id === avalilablePlugin.id, + ), + ) + .filter(plg => filter.includes(plg.lang)), 'name', ), ); diff --git a/src/navigators/MoreStack.tsx b/src/navigators/MoreStack.tsx index 3b90bab65..aaf31a859 100644 --- a/src/navigators/MoreStack.tsx +++ b/src/navigators/MoreStack.tsx @@ -14,6 +14,7 @@ import DownloadQueue from '../screens/more/DownloadQueueScreen'; import Downloads from '../screens/more/DownloadsScreen'; import AppearanceSettings from '../screens/settings/SettingsAppearanceScreen'; import CategoriesScreen from '@screens/Categories/CategoriesScreen'; +import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen'; // import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen'; import StatsScreen from '@screens/StatsScreen/StatsScreen'; import { MoreStackParamList, SettingsStackParamList } from './types'; @@ -33,6 +34,7 @@ const SettingsStack = () => ( + {/* */} ); diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index 8b1b61a17..b7284e0e4 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -80,6 +80,7 @@ export type SettingsStackParamList = { AppearanceSettings: undefined; AdvancedSettings: undefined; LibrarySettings: undefined; + RespositorySettings: undefined; }; export type NovelScreenProps = StackScreenProps; diff --git a/src/plugins/helpers/htmlToText.js b/src/plugins/helpers/htmlToText.js deleted file mode 100644 index 3ea1847fe..000000000 --- a/src/plugins/helpers/htmlToText.js +++ /dev/null @@ -1,266 +0,0 @@ -const htmlToText = (html, options = {}) => { - const { removeLineBreaks = true } = options; - - if (!html) { - return "Chapter is empty.\n\nReport if it's available in webview."; - } - - html = html.trim(); - - let text = removeLineBreaks && html.replace(/(?:\n|\r\n|\r)/gi, ''); - - text = html - /** - *
-> \n - */ - - .replace(/<\s*br[^>]*>/gi, '\n') - - /** - *
-> [hr]() - */ - - .replace(/<\s*hr[^>]*>/gi, '') - - /** - *
  • -> \n - */ - .replace(/<\s*\/li[^>]*>/gi, '\n') - - /** - *

    -> \n\n - */ - - .replace(/<\s*p[^>]*>/gi, '\n\n') - - /** - * Remove script and noscript tags - */ - .replace(/<\s*script[^>]*>[\s\S]*?<\/script>/gim, '') - .replace(/<\s*noscript[^>]*>[\s\S]*?<\/noscript>/gim, '') - - /** - * Remove style tags - */ - - .replace(/<\s*style[^>]*>[\s\S]*?<\/style>/gim, '') - - /** - * Remove comments - */ - - .replace(//gim, '') - - /** - * Text -> [Text](link) - */ - - .replace( - /<\s*a[^>]*href=['"](http.*?)['"][^>]*>([\s\S]*?)<\/\s*a\s*>/gi, - '$2', - ) - // .replace( - // /<\s*a[^>]*href=['"](http.*?)['"][^>]*>([\s\S]*?)<\/\s*a\s*>/gi, - // "[$2]($1)" - // ) - /** - * -> [img](src) - */ - - .replace( - /<\s*img[^>]*[src|data-src]=['"](.*?)['"][^>]*>/gi, - '[Image]($1)\n', - ) - - /** - * Remove remaining tags - */ - - .replace(/(<([^>-]+)>)/gi, '') - - /** - * Remove tabs. - */ - - .replace(/\t/g, '') - - /** - * Remove newlines at the beginning of the text. - */ - - .replace(/^\n+/m, '') - - /** - * Replace multiple spaces with a single space. - */ - - .replace(/ {2,}/g, ' ') - - /** - * Make sure there are never more than two consecutive linebreaks. - */ - - .replace(/\n{2,}/g, '\n\n') - - /** - * Decode HTML entities. - */ - - .replace(/&([^;]+);/g, decodeHtmlEntity) - - .trim(); - - return text; -}; - -const decodeHtmlEntity = (m, n) => { - let code; - - if (n.substr(0, 1) === '#') { - if (n.substr(1, 1) === 'x') { - code = parseInt(n.substr(2), 16); - } else { - code = parseInt(n.substr(1), 10); - } - } else { - code = ENTITIES_MAP[n]; - } - - return code === undefined || isNaN(code) - ? '&' + n + ';' - : String.fromCharCode(code); -}; - -let ENTITIES_MAP = { - nbsp: 160, - iexcl: 161, - cent: 162, - pound: 163, - curren: 164, - yen: 165, - brvbar: 166, - sect: 167, - uml: 168, - copy: 169, - ordf: 170, - laquo: 171, - not: 172, - shy: 173, - reg: 174, - macr: 175, - deg: 176, - plusmn: 177, - sup2: 178, - sup3: 179, - acute: 180, - micro: 181, - para: 182, - middot: 183, - cedil: 184, - sup1: 185, - ordm: 186, - raquo: 187, - frac14: 188, - frac12: 189, - frac34: 190, - iquest: 191, - Agrave: 192, - Aacute: 193, - Acirc: 194, - Atilde: 195, - Auml: 196, - Aring: 197, - AElig: 198, - Ccedil: 199, - Egrave: 200, - Eacute: 201, - Ecirc: 202, - Euml: 203, - Igrave: 204, - Iacute: 205, - Icirc: 206, - Iuml: 207, - ETH: 208, - Ntilde: 209, - Ograve: 210, - Oacute: 211, - Ocirc: 212, - Otilde: 213, - Ouml: 214, - times: 215, - Oslash: 216, - Ugrave: 217, - Uacute: 218, - Ucirc: 219, - Uuml: 220, - Yacute: 221, - THORN: 222, - szlig: 223, - agrave: 224, - aacute: 225, - acirc: 226, - atilde: 227, - auml: 228, - aring: 229, - aelig: 230, - ccedil: 231, - egrave: 232, - eacute: 233, - ecirc: 234, - euml: 235, - igrave: 236, - iacute: 237, - icirc: 238, - iuml: 239, - eth: 240, - ntilde: 241, - ograve: 242, - oacute: 243, - ocirc: 244, - otilde: 245, - ouml: 246, - divide: 247, - oslash: 248, - ugrave: 249, - uacute: 250, - ucirc: 251, - uuml: 252, - yacute: 253, - thorn: 254, - yuml: 255, - quot: 34, - amp: 38, - lt: 60, - gt: 62, - OElig: 338, - oelig: 339, - Scaron: 352, - scaron: 353, - Yuml: 376, - circ: 710, - tilde: 732, - ensp: 8194, - emsp: 8195, - thinsp: 8201, - zwnj: 8204, - zwj: 8205, - lrm: 8206, - rlm: 8207, - ndash: 8211, - mdash: 8212, - lsquo: 8216, - rsquo: 8217, - sbquo: 8218, - ldquo: 8220, - rdquo: 8221, - bdquo: 8222, - dagger: 8224, - Dagger: 8225, - permil: 8240, - lsaquo: 8249, - rsaquo: 8250, - euro: 8364, - apos: 39, -}; - -export { htmlToText }; diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index a0f9e905d..29e12603b 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -1,4 +1,5 @@ import RNFS from 'react-native-fs'; +import { reverse, uniqBy } from 'lodash-es'; import { PluginDownloadFolder } from '@utils/constants/download'; import { newer } from '@utils/compareVersion'; @@ -14,6 +15,7 @@ import { defaultCover } from './helpers/constants'; import { encode, decode } from 'urlencode'; import { Parser } from 'htmlparser2'; import TextFile from '@native/TextFile'; +import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries'; const pluginsFilePath = PluginDownloadFolder + '/plugins.json'; @@ -126,14 +128,17 @@ const updatePlugin = async (plugin: PluginItem) => { return installPlugin(plugin.url); }; -const fetchPlugins = (): Promise => { - // plugins host - const githubUsername = 'LNReader'; - const githubRepository = 'lnreader-sources'; - const pluginsTag = 'v2.1.0'; - return fetch( - `https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/plugins/${pluginsTag}/.dist/plugins.min.json`, - ).then(res => res.json()); +const fetchPlugins = async (): Promise => { + const allPlugins: PluginItem[] = []; + const allRepositories = await getRepositoriesFromDb(); + + const repoPluginsRes = await Promise.all( + allRepositories.map(({ url }) => fetch(url).then(res => res.json())), + ); + + repoPluginsRes.forEach(repoPlugins => allPlugins.push(...repoPlugins)); + + return uniqBy(reverse(allPlugins), 'id'); }; const getPlugin = (pluginId: string) => plugins[pluginId]; diff --git a/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx new file mode 100644 index 000000000..2cc674ba7 --- /dev/null +++ b/src/screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { FlatList, StyleSheet } from 'react-native'; +import { FAB, Portal } from 'react-native-paper'; +import { useNavigation } from '@react-navigation/native'; + +import { Appbar, EmptyView } from '@components'; + +import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries'; +import { Repository } from '@database/types'; +import { useBoolean } from '@hooks/index'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; + +import AddRepositoryModal from './components/AddRepositoryModal'; +import CategorySkeletonLoading from '@screens/Categories/components/CategorySkeletonLoading'; +import RepositoryCard from './components/RepositoryCard'; + +const SettingsBrowseScreen = () => { + const navigation = useNavigation(); + const theme = useTheme(); + + const [isLoading, setIsLoading] = useState(true); + const [repositories, setRepositories] = useState(); + + const getRepositories = async () => { + try { + let res = await getRepositoriesFromDb(); + setRepositories(res); + } catch (err) { + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getRepositories(); + }, []); + + const { + value: addRepositoryModalVisible, + setTrue: showAddRepositoryModal, + setFalse: closeAddRepositoryModal, + } = useBoolean(); + + return ( + <> + + {isLoading ? ( + + ) : ( + ( + + )} + ListEmptyComponent={ + + } + /> + )} + + + + + + ); +}; + +export default SettingsBrowseScreen; + +const styles = StyleSheet.create({ + fab: { + position: 'absolute', + margin: 16, + right: 0, + bottom: 16, + }, + contentCtn: { + flexGrow: 1, + paddingVertical: 16, + paddingBottom: 100, + }, +}); diff --git a/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx new file mode 100644 index 000000000..ef273cfec --- /dev/null +++ b/src/screens/settings/SettingsRepositoryScreen/components/AddRepositoryModal.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Modal, overlay, Portal, TextInput } from 'react-native-paper'; + +import { Button } from '@components/index'; + +import { Repository } from '@database/types'; +import { + createRepository, + isRepoUrlDuplicate, + updateRepository, +} from '@database/queries/RepositoryQueries'; +import { useTheme } from '@hooks/persisted'; + +import { getString } from '@strings/translations'; +import { showToast } from '@utils/showToast'; + +interface AddRepositoryModalProps { + isEditMode?: boolean; + repository?: Repository; + visible: boolean; + closeModal: () => void; + onSuccess: () => Promise; +} + +const AddRepositoryModal: React.FC = ({ + isEditMode, + repository, + closeModal, + visible, + onSuccess, +}) => { + const theme = useTheme(); + const [repositoryUrl, setRepositoryUrl] = useState(repository?.url || ''); + + return ( + + + + {isEditMode ? 'Edit repository' : 'Add repository'} + + + +